1use sqlx::PgPool;
6
7const LEGACY_BOOTSTRAP_CUTOFF_VERSION: &str = "202606181200";
9const PREVIOUS_MIGRATION_VERSION: &str = "202607171900";
11const LATEST_MIGRATION_VERSION: &str = "202607190130";
13
14struct Migration {
16 version: &'static str,
18 name: &'static str,
20 sql: &'static str,
22}
23
24const MIGRATIONS: &[Migration] = &[
26 Migration {
27 version: "202606170000",
28 name: "postgresql_baseline",
29 sql: include_str!("../../migrations/V202606170000__postgresql_baseline.sql"),
30 },
31 Migration {
32 version: "202606171200",
33 name: "Add_select_query_indexes",
34 sql: include_str!("../../migrations/V202606171200__Add_select_query_indexes.sql"),
35 },
36 Migration {
37 version: "202606171400",
38 name: "Add_pg_trgm_fuzzy_indexes",
39 sql: include_str!("../../migrations/V202606171400__Add_pg_trgm_fuzzy_indexes.sql"),
40 },
41 Migration {
42 version: "202606181200",
43 name: "Fix_unpublished_episode_indexes",
44 sql: include_str!("../../migrations/V202606181200__Fix_unpublished_episode_indexes.sql"),
45 },
46 Migration {
47 version: "202606201000",
48 name: "Add_editor_tables",
49 sql: include_str!("../../migrations/V202606201000__Add_editor_tables.sql"),
50 },
51 Migration {
52 version: "202606201300",
53 name: "Add_member_avatar_url",
54 sql: include_str!("../../migrations/V202606201300__Add_member_avatar_url.sql"),
55 },
56 Migration {
57 version: "202606201500",
58 name: "Add_global_search_trgm_indexes",
59 sql: include_str!("../../migrations/V202606201500__Add_global_search_trgm_indexes.sql"),
60 },
61 Migration {
62 version: "202606201610",
63 name: "Create_episode_role_taker",
64 sql: include_str!("../../migrations/V202606201610__Create_episode_role_taker.sql"),
65 },
66 Migration {
67 version: "202606211420",
68 name: "Fix_editor_page_id_bigint",
69 sql: include_str!("../../migrations/V202606211420__Fix_editor_page_id_bigint.sql"),
70 },
71 Migration {
72 version: "202606211500",
73 name: "Fix_editor_timestamp_utc",
74 sql: include_str!("../../migrations/V202606211500__Fix_editor_timestamp_utc.sql"),
75 },
76 Migration {
77 version: "202606211510",
78 name: "Force_editor_timestamp_utc",
79 sql: include_str!("../../migrations/V202606211510__Force_editor_timestamp_utc.sql"),
80 },
81 Migration {
82 version: "202607020900",
83 name: "Create_gray_release_tables",
84 sql: include_str!("../../migrations/V202607020900__Create_gray_release_tables.sql"),
85 },
86 Migration {
87 version: "202607171900",
88 name: "Add_episode_type",
89 sql: include_str!("../../migrations/V202607171900__Add_episode_type.sql"),
90 },
91 Migration {
92 version: "202607190130",
93 name: "Deduplicate_workreminder",
94 sql: include_str!("../../migrations/V202607190130__Deduplicate_workreminder.sql"),
95 },
96];
97
98pub async fn run(pool: &PgPool) -> anyhow::Result<()> {
100 ensure_migration_table(pool).await?;
101
102 bootstrap_existing_database(pool).await?;
103
104 for migration in MIGRATIONS {
105 if is_applied(pool, migration.version).await? {
106 continue;
107 }
108 apply_migration(pool, migration).await?;
109 }
110
111 Ok(())
112}
113
114async fn ensure_migration_table(pool: &PgPool) -> anyhow::Result<()> {
116 sqlx::query(
117 r#"
118 CREATE TABLE IF NOT EXISTS __tdm_schema_migrations (
119 version TEXT PRIMARY KEY,
120 name TEXT NOT NULL,
121 applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
122 )
123 "#,
124 )
125 .execute(pool)
126 .await?;
127 Ok(())
128}
129
130async fn bootstrap_existing_database(pool: &PgPool) -> anyhow::Result<()> {
132 let applied_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM __tdm_schema_migrations")
133 .fetch_one(pool)
134 .await?;
135
136 let membertb: Option<String> =
137 sqlx::query_scalar("SELECT to_regclass('public.membertb')::text")
138 .fetch_one(pool)
139 .await?;
140 let core_schema_present = membertb.is_some();
141
142 let previous_schema_present: bool = sqlx::query_scalar(
144 r#"
145 SELECT EXISTS (
146 SELECT 1
147 FROM information_schema.columns
148 WHERE table_schema = 'public'
149 AND table_name = 'mangaepisodetb'
150 AND column_name = 'episodeType'
151 )
152 "#,
153 )
154 .fetch_one(pool)
155 .await?;
156
157 let latest_schema_present: bool =
159 sqlx::query_scalar("SELECT to_regclass('public.uk_workreminder_episode_post') IS NOT NULL")
160 .fetch_one(pool)
161 .await?;
162
163 if let Some(cutoff) = bootstrap_cutoff(
164 applied_count,
165 core_schema_present,
166 previous_schema_present,
167 latest_schema_present,
168 ) {
169 mark_history_through(pool, cutoff).await?;
170 }
171
172 Ok(())
173}
174
175fn bootstrap_cutoff(
176 applied_count: i64,
177 core_schema_present: bool,
178 previous_schema_present: bool,
179 latest_schema_present: bool,
180) -> Option<&'static str> {
181 if !core_schema_present {
182 None
183 } else if latest_schema_present {
184 Some(LATEST_MIGRATION_VERSION)
185 } else if applied_count > 0 {
186 None
187 } else if previous_schema_present {
188 Some(PREVIOUS_MIGRATION_VERSION)
189 } else {
190 Some(LEGACY_BOOTSTRAP_CUTOFF_VERSION)
191 }
192}
193
194async fn mark_history_through(pool: &PgPool, cutoff_version: &str) -> anyhow::Result<()> {
196 for migration in MIGRATIONS {
197 if migration.version <= cutoff_version {
198 mark_applied(pool, migration).await?;
199 }
200 }
201 tracing::info!("既有数据库迁移记录初始化完成 cutoff={cutoff_version}");
202 Ok(())
203}
204
205async fn is_applied(pool: &PgPool, version: &str) -> anyhow::Result<bool> {
207 let app_applied: Option<String> =
209 sqlx::query_scalar("SELECT version FROM __tdm_schema_migrations WHERE version = $1")
210 .bind(version)
211 .fetch_optional(pool)
212 .await?;
213 if app_applied.is_some() {
214 return Ok(true);
215 }
216
217 let flyway_history_exists: bool =
218 sqlx::query_scalar("SELECT to_regclass('flyway_schema_history') IS NOT NULL")
219 .fetch_one(pool)
220 .await?;
221 if !flyway_history_exists {
222 return Ok(false);
223 }
224
225 let flyway_applied: Option<String> = sqlx::query_scalar(
228 "SELECT version FROM flyway_schema_history WHERE version = $1 AND success = true",
229 )
230 .bind(version)
231 .fetch_optional(pool)
232 .await?;
233 if flyway_applied.is_some() {
234 sqlx::query(
236 "INSERT INTO __tdm_schema_migrations(version, name) VALUES ($1, $2) ON CONFLICT (version) DO NOTHING",
237 )
238 .bind(version)
239 .bind("synced_from_flyway")
240 .execute(pool)
241 .await?;
242 return Ok(true);
243 }
244 Ok(false)
245}
246
247async fn apply_migration(pool: &PgPool, migration: &Migration) -> anyhow::Result<()> {
249 tracing::info!(
250 "开始执行数据库迁移 version={} name={}",
251 migration.version,
252 migration.name
253 );
254 let mut tx = pool.begin().await?;
255 sqlx::raw_sql(migration.sql).execute(&mut *tx).await?;
256 sqlx::query(
257 r#"
258 INSERT INTO __tdm_schema_migrations(version, name)
259 VALUES ($1, $2)
260 ON CONFLICT (version) DO NOTHING
261 "#,
262 )
263 .bind(migration.version)
264 .bind(migration.name)
265 .execute(&mut *tx)
266 .await?;
267 tx.commit().await?;
268 tracing::info!(
269 "数据库迁移执行完成 version={} name={}",
270 migration.version,
271 migration.name
272 );
273 Ok(())
274}
275
276async fn mark_applied(pool: &PgPool, migration: &Migration) -> anyhow::Result<()> {
278 sqlx::query(
279 r#"
280 INSERT INTO __tdm_schema_migrations(version, name)
281 VALUES ($1, $2)
282 ON CONFLICT (version) DO NOTHING
283 "#,
284 )
285 .bind(migration.version)
286 .bind(migration.name)
287 .execute(pool)
288 .await?;
289 Ok(())
290}
291
292#[cfg(test)]
293mod tests {
294 use super::{
295 bootstrap_cutoff, LATEST_MIGRATION_VERSION, LEGACY_BOOTSTRAP_CUTOFF_VERSION, MIGRATIONS,
296 PREVIOUS_MIGRATION_VERSION,
297 };
298
299 #[test]
300 fn bootstrap_cutoff_matches_database_shape() {
301 assert_eq!(bootstrap_cutoff(0, false, false, false), None);
302 assert_eq!(
303 bootstrap_cutoff(0, true, false, false),
304 Some(LEGACY_BOOTSTRAP_CUTOFF_VERSION)
305 );
306 assert_eq!(
307 bootstrap_cutoff(0, true, true, false),
308 Some(PREVIOUS_MIGRATION_VERSION)
309 );
310 assert_eq!(
311 bootstrap_cutoff(0, true, true, true),
312 Some(LATEST_MIGRATION_VERSION)
313 );
314 assert_eq!(
315 bootstrap_cutoff(4, true, true, true),
316 Some(LATEST_MIGRATION_VERSION)
317 );
318 assert_eq!(bootstrap_cutoff(4, true, true, false), None);
319 }
320
321 #[test]
323 fn latest_migration_version_matches_embedded_list() {
324 assert_eq!(LATEST_MIGRATION_VERSION, "202607190130");
325 assert_eq!(
326 MIGRATIONS.last().map(|migration| migration.version),
327 Some("202607190130")
328 );
329 }
330
331 fn assert_migration_lock_order(sql: &str, lock_marker: &str) {
333 let lock = sql.find(lock_marker).expect("迁移必须先取得写入阻断锁");
334 let delete = sql.find("DELETE").expect("迁移必须清理历史重复行");
335 let index = sql
336 .find("CREATE UNIQUE INDEX")
337 .expect("迁移必须创建唯一索引");
338 assert!(lock < delete, "写入阻断锁必须早于重复清理");
339 assert!(delete < index, "重复清理必须早于唯一索引创建");
340 }
341
342 #[test]
344 fn dedup_migrations_lock_before_cleanup_and_unique_index() {
345 let postgres = include_str!("../../migrations/V202607190130__Deduplicate_workreminder.sql");
346 let mysql =
347 include_str!("../../migrations/mysql/V202607190130__Deduplicate_workreminder.sql");
348
349 assert_migration_lock_order(
350 postgres,
351 "LOCK TABLE workreminder IN SHARE ROW EXCLUSIVE MODE",
352 );
353 assert_migration_lock_order(mysql, "LOCK TABLES");
354 for lock_target in [
355 "workreminder WRITE",
356 "workreminder AS older WRITE",
357 "workreminder AS newer WRITE",
358 ] {
359 assert!(
360 mysql.contains(lock_target),
361 "MySQL 缺少锁目标: {lock_target}"
362 );
363 }
364 assert!(
365 mysql.find("CREATE UNIQUE INDEX").unwrap() < mysql.find("UNLOCK TABLES").unwrap(),
366 "MySQL 应在唯一索引创建后释放表锁"
367 );
368 }
369}