Skip to main content

yggdrasil/db/
migrate.rs

1//! 数据库迁移运行器。
2//!
3//! 在服务器开始监听之前自动执行迁移。
4//! 设计要点:
5//! - 迁移 SQL 通过 `include_str!` 内联进二进制,部署只需单个二进制。
6//! - `schema_migrations` 表记录已应用版本,避免重复执行。
7//! - 每个迁移在独立事务里执行,失败自动回滚,版本行不会写入。
8//! - 咨询锁(`pg_advisory_lock`)保证多实例启动时只有一个进程执行迁移。
9//!
10//! 前置条件:目标数据库的存在性由 [`crate::db::pool::ensure_database`] 在本模块
11//! 被调用前保证(它先连 `postgres` 维护库做 `CREATE DATABASE IF NOT EXISTS` 等价逻辑)。
12//! 本模块负责 schema 与一次性存储格式迁移,不再关心"库不存在"。
13//!
14//! 仅在 `feature = "server"` 时编译。
15
16use std::collections::HashSet;
17
18/// 咨询锁的固定 key。Postgres 咨询锁是数据库级唯一的;
19/// 这里用一个项目专属的大整数,避免与同库其它应用冲突。
20/// 该值无语义,仅要求唯一性。
21const ADVISORY_LOCK_KEY: i64 = 0x5947_4752_4153_494C;
22
23/// 所有迁移的 (version, sql) 列表,按 version 升序排列。
24///
25/// 新增迁移时:
26/// 1. 在 `migrations/` 下创建 `NNN_描述.sql`
27/// 2. 在本数组末尾追加一行 `("NNN", include_str!("../../migrations/NNN_描述.sql"))`
28///
29/// version 用字符串而非整数,与文件名前缀直接对应,且未来可支持日期戳/语义版本。
30const 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    // 新增迁移在此追加,同时在 migrations/ 下创建对应 .sql 文件。
118];
119
120/// 迁移执行错误。
121#[derive(Debug)]
122pub enum MigrateError {
123    /// 无法从连接池获取连接。
124    Pool(deadpool_postgres::PoolError),
125    /// 执行查询(建表、查版本、咨询锁等)失败。
126    Query(tokio_postgres::Error),
127    /// 某个具体迁移执行失败(包含版本号便于定位)。
128    Apply {
129        version: String,
130        source: tokio_postgres::Error,
131    },
132    /// 迁移 026 的 HTML 解析/改写失败;整笔迁移回滚,不丢弃正文。
133    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        // 主动展开 source 链:tokio_postgres::Error 的 Display 对 DB 侧错误只打印
155        // 无信息量的 `db error`,真正的 message/SQLSTATE/约束名藏在 source() 里。
156        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
200/// 在**已获取**的连接上执行迁移主体逻辑(咨询锁 + 建表 + 应用迁移 + 解锁)。
201///
202/// 调用方负责自行控制连接获取策略——例如 `main.rs` 启动时用
203/// [`get_conn_for_startup`](crate::db::pool::get_conn_for_startup)(长重试窗口)
204/// 拿到连接后再调用本函数,以应对 DB 尚未就绪的场景。
205///
206/// 流程:
207/// 1. 抢咨询锁(多实例启动时串行化)
208/// 2. 确保 `schema_migrations` 表存在
209/// 3. 查询已应用版本集合
210/// 4. 按序应用未应用的迁移(每个一个事务)
211/// 5. 释放咨询锁
212///
213/// 失败时返回错误;调用方(`main.rs`)应让进程退出,避免启动半残服务。
214pub async fn run_on_conn(conn: &mut deadpool_postgres::Object) -> Result<(), MigrateError> {
215    // 抢咨询锁:多实例滚动发布时只有一个进程能进入迁移循环,
216    // 其余实例在此等待;锁释放后它们查版本表发现已全部应用,直接返回。
217    conn.execute("SELECT pg_advisory_lock($1)", &[&ADVISORY_LOCK_KEY])
218        .await?;
219
220    // 在已持锁连接上执行迁移主体逻辑。
221    // 锁释放策略:
222    // - 正常返回 / 返回 Err:下面的显式 pg_advisory_unlock 释放锁。
223    // - 进程被强杀(SIGKILL 等):连接断开,Postgres 在检测到会话断开后释放
224    //   session 级咨询锁。
225    // - `main.rs` 在迁移失败时用 `std::process::exit(1)` 终止进程(不再 panic):
226    //   `exit(1)` 不会 unwind,但同样会关闭进程持有的所有 socket / 池连接,
227    //   效果等价于会话断开——Postgres 会释放 session 级咨询锁。
228    //   因此把 `.expect()` 改成 `exit(1)` 不破坏原有的锁安全保证。
229    let result = run_inner(conn).await;
230
231    // 无论成功失败都尝试显式释放锁;释放失败不应掩盖原始错误,仅记录告警。
232    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
242/// 在已持有咨询锁的连接上执行迁移主体逻辑。
243async fn run_inner(conn: &mut deadpool_postgres::Object) -> Result<(), MigrateError> {
244    // 确保版本表存在(独立语句,不在事务里,否则建表失败无法记录)。
245    ensure_versions_table(conn).await?;
246
247    // 查询已应用的版本集合。
248    let applied = applied_versions(conn).await?;
249
250    // 按序应用未应用的迁移。
251    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
269/// 创建 `schema_migrations` 表(若不存在)。
270async 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
281/// 查询已应用的版本集合。
282async 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
295/// 在一个事务内应用单个迁移:执行 SQL + 写入版本行,失败则回滚。
296async 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    // batch_execute 执行整段 SQL(可含多条语句)。
304    if let Err(e) = tx.batch_execute(sql).await {
305        // 显式回滚以尽早释放事务(而非等 Transaction drop 的隐式回滚);
306        // 回滚本身的错误丢弃,因为已有更具信息量的 apply 错误要上报。
307        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    // 记录版本行。显式构造 Apply 错误(不能用 ?,否则会被 blanket From 映射成 Query)。
322    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
343/// 一次性格式迁移:不重渲染 Markdown、不触碰原文/目录/时间戳/审核状态。
344/// SQL 已锁住两张表,批次仅限制内存;所有批次与版本行仍属于同一事务。
345async 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    // 只暂停时间戳触发器;外键等完整性约束照常执行。失败时 DDL 一同回滚。
351    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        // table 仅来自上面的常量列表,绝不接受外部输入。
357        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
398/// KaTeX 0.18 的精确 class 改名表(上游 commit 6f5c44f,katex-rs 0.3)。
399/// `hbox` 的未使用 CSS 被上游删除,没有对应的旧渲染节点需要迁移。
400fn 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
426/// 仅修改公式视觉层 span 的完整 class token,保留文字、其它属性和注释。
427/// lol_html 不重新序列化未改动的节点;无旧 class 返回 None,避免无效 UPDATE。
428fn 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 &amp; underline</p><pre><code>&lt;span class="base"&gt;</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 &amp; overline">overline &amp; 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 &amp; overline">overline &amp; 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    /// 防止"新建了 .sql 但忘记在 MIGRATIONS 加行"的脚枪。
512    /// 扫描 migrations/ 目录,断言每个 .sql 文件都在 MIGRATIONS 里有对应版本行。
513    /// 仅在 server feature + test 下运行(WASM 无文件系统)。
514    #[test]
515    fn migrations_match_files_on_disk() {
516        use std::collections::HashSet;
517        use std::fs;
518
519        // CARGO_MANIFEST_DIR 指向 crate 根目录(yggdrasil/)。
520        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                // 文件名形如 "001_init.sql",取前 3 位数字作为 version。
535                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        // 磁盘上有但数组里没有 → 忘记加行(会静默不执行该迁移)。
547        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        // 数组里有但磁盘上没有 → include_str! 本就会编译失败,这里只是双保险。
556        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}