//! CI guard for migration safety hygiene (forward-only). //! //! sqlx checksums every *applied* migration, so historical migrations can never //! be edited, retrofitting `CONCURRENTLY` / `IF NOT EXISTS` onto migrations //! `001..=HIGH_WATER` is impossible without breaking the checksum guard. This //! test therefore freezes the existing set and enforces the conventions only on //! NEW migrations (number > `HIGH_WATER`): //! //! 1. A `CREATE INDEX` on a high-write "growth" table must be `CONCURRENTLY`. //! A plain `CREATE INDEX` takes an `ACCESS EXCLUSIVE` lock and blocks writes //! for the whole build; on a grown `transactions`/`page_views` table that is //! a production write-stall. //! 2. `CREATE INDEX CONCURRENTLY` cannot run inside a transaction, so any file //! that uses it must opt out of sqlx's per-migration transaction by starting //! with the exact bytes `-- no-transaction` (sqlx-core 0.8 `source.rs:127` //! does `sql.starts_with("-- no-transaction")`, it must be the file prefix, //! no leading blank line). //! 3. `CREATE TABLE` / `CREATE INDEX` must be `IF NOT EXISTS` (re-run safety, //! a partially-applied migration set can be re-run without erroring). //! //! Bump `HIGH_WATER` only after deliberately reviewing every migration at or below //! the new mark and accepting its state. //! //! Run with: cargo test --test migration_hygiene use std::fs; use std::path::Path; /// Highest migration number whose contents are grandfathered. Landed at the /// current max during the Run 23 remediation (2026-07-04). Anything strictly /// greater must satisfy the conventions above. const HIGH_WATER: u32 = 167; /// Tables large/hot enough that a blocking (non-concurrent) index build is a real /// production write-stall. A `CREATE INDEX` touching one of these in a new /// migration must be `CONCURRENTLY`. const GROWTH_TABLES: &[&str] = &[ "transactions", "page_views", "follows", "subscriptions", "items", "item_versions", "sync_blobs", "webhook_events", "processed_webhook_events", ]; const MIGRATIONS_DIR: &str = "migrations"; #[test] fn new_migrations_follow_index_and_rerun_safety_conventions() { let mut violations = Vec::new(); let mut files: Vec<_> = fs::read_dir(MIGRATIONS_DIR) .expect("read migrations dir") .filter_map(std::result::Result::ok) .map(|e| e.path()) .filter(|p| p.extension().is_some_and(|x| x == "sql")) .collect(); files.sort(); for path in &files { let Some(num) = leading_number(path) else { violations.push(format!("{}: filename has no leading number", show(path))); continue; }; if num <= HIGH_WATER { continue; // grandfathered, frozen by sqlx checksum } let raw = fs::read_to_string(path).expect("read migration"); let no_tx = raw.starts_with("-- no-transaction"); let stmts = statements(&raw); for stmt in &stmts { if let Some(idx) = parse_create_index(stmt) { if idx.growth_target() && !idx.concurrently { violations.push(format!( "{}: CREATE INDEX on growth table `{}` must be CONCURRENTLY \ (plain build takes ACCESS EXCLUSIVE and stalls writes)", show(path), idx.table )); } if idx.concurrently && !no_tx { violations.push(format!( "{}: uses CREATE INDEX CONCURRENTLY but the file does not start \ with `-- no-transaction` — sqlx will wrap it in a transaction \ and Postgres will reject it at runtime", show(path) )); } if !idx.if_not_exists { violations.push(format!( "{}: CREATE INDEX must be IF NOT EXISTS for re-run safety", show(path) )); } } else if is_create_table(stmt) && !has_if_not_exists(stmt) { violations.push(format!( "{}: CREATE TABLE must be IF NOT EXISTS for re-run safety", show(path) )); } } } assert!( violations.is_empty(), "migration hygiene violations (see tests/migration_hygiene.rs for the rules):\n{}", violations.join("\n") ); } /// The guard must not silently pass because every new migration was skipped by a /// wrong `HIGH_WATER`: assert the mark is not below the real maximum on disk. /// (If someone adds `168_*.sql`, the mark stays at 167 and 168 is checked, good. /// This only fires if `HIGH_WATER` is set *above* the newest file, which would /// disable the guard entirely.) #[test] fn high_water_mark_is_not_ahead_of_disk() { let max = fs::read_dir(MIGRATIONS_DIR) .expect("read migrations dir") .filter_map(std::result::Result::ok) .filter_map(|e| leading_number(&e.path())) .max() .expect("at least one migration"); assert!( HIGH_WATER <= max, "HIGH_WATER ({HIGH_WATER}) is ahead of the newest migration ({max}) — \ the hygiene guard would skip every file. Lower HIGH_WATER." ); } // ---- helpers ------------------------------------------------------------- fn show(p: &Path) -> String { p.file_name().unwrap().to_string_lossy().into_owned() } fn leading_number(p: &Path) -> Option { let name = p.file_name()?.to_str()?; let digits: String = name.chars().take_while(char::is_ascii_digit).collect(); digits.parse().ok() } /// Split into statements: strip `--` line comments and `/* */` block comments, /// lowercase, collapse whitespace, split on `;`. fn statements(raw: &str) -> Vec { // Strip block comments. Uses `str::find` (whose offsets are always char // boundaries) and `push_str`, so it is UTF-8-safe, an earlier byte-index // version panicked when a migration comment contained a multibyte char // like '...'. let mut no_block = String::with_capacity(raw.len()); let mut rest = raw; while let Some(start) = rest.find("/*") { no_block.push_str(&rest[..start]); no_block.push(' '); match rest[start + 2..].find("*/") { Some(end) => rest = &rest[start + 2 + end + 2..], None => { rest = ""; break; } } } no_block.push_str(rest); // Strip line comments and normalize. let cleaned: String = no_block .lines() .map(|l| match l.find("--") { Some(pos) => &l[..pos], None => l, }) .collect::>() .join(" "); let norm = cleaned.to_lowercase(); norm.split(';') .map(|s| s.split_whitespace().collect::>().join(" ")) .filter(|s| !s.is_empty()) .collect() } fn is_create_table(stmt: &str) -> bool { stmt.starts_with("create table") || stmt.starts_with("create unlogged table") } fn has_if_not_exists(stmt: &str) -> bool { stmt.contains("if not exists") } struct IndexStmt { concurrently: bool, if_not_exists: bool, table: String, } impl IndexStmt { fn growth_target(&self) -> bool { GROWTH_TABLES.contains(&self.table.as_str()) } } /// Parse a `CREATE [UNIQUE] INDEX [CONCURRENTLY] [IF NOT EXISTS] name ON table ...`. /// Returns None if the statement is not a create-index. fn parse_create_index(stmt: &str) -> Option { let tokens: Vec<&str> = stmt.split_whitespace().collect(); // Must begin create [unique] index let idx_pos = tokens.iter().position(|t| *t == "index")?; if tokens.first() != Some(&"create") { return None; } match idx_pos { 1 => {} // create index 2 if tokens.get(1) == Some(&"unique") => {} // create unique index _ => return None, } let after: Vec<&str> = tokens[idx_pos + 1..].to_vec(); let concurrently = after.first() == Some(&"concurrently"); let if_not_exists = stmt.contains("if not exists"); // Table name is the token following `on`. let on_pos = after.iter().position(|t| *t == "on")?; let raw_table = after.get(on_pos + 1)?; let table = raw_table .trim_matches(|c| c == '"' || c == '(') .split('(') .next() .unwrap_or(raw_table) .to_string(); Some(IndexStmt { concurrently, if_not_exists, table, }) } // ---- self-tests: prove the detector fires, so the on-disk pass is not vacuous ---- #[cfg(test)] mod detector_tests { use super::*; fn only(stmt: &str) -> String { statements(stmt).into_iter().next().unwrap() } #[test] fn flags_plain_index_on_growth_table() { let idx = parse_create_index(&only( "CREATE INDEX IF NOT EXISTS idx_x ON transactions (seller_user_id);", )) .unwrap(); assert!(idx.growth_target()); assert!(!idx.concurrently, "plain build must be caught"); assert!(idx.if_not_exists); } #[test] fn accepts_concurrent_index_on_growth_table() { let idx = parse_create_index(&only( "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_x ON transactions (created_at);", )) .unwrap(); assert!(idx.growth_target()); assert!(idx.concurrently); assert!(idx.if_not_exists); } #[test] fn small_table_index_is_not_a_growth_target() { let idx = parse_create_index(&only("CREATE INDEX IF NOT EXISTS i ON promo_codes (id);")).unwrap(); assert!(!idx.growth_target(), "non-hot table needn't be concurrent"); } #[test] fn detects_missing_if_not_exists() { let idx = parse_create_index(&only("CREATE INDEX i ON items (slug);")).unwrap(); assert!(!idx.if_not_exists); assert!(idx.growth_target()); } #[test] fn no_transaction_directive_must_be_file_prefix() { // sqlx does sql.starts_with("-- no-transaction"), a leading blank line breaks it. assert!( "-- no-transaction\nCREATE INDEX CONCURRENTLY ...".starts_with("-- no-transaction") ); assert!(!"\n-- no-transaction\n...".starts_with("-- no-transaction")); } #[test] fn unique_index_parsed_and_table_extracted_without_paren() { let idx = parse_create_index(&only( "CREATE UNIQUE INDEX CONCURRENTLY i ON subscriptions(user_id);", )) .unwrap(); assert_eq!(idx.table, "subscriptions"); assert!(idx.concurrently); } #[test] fn create_table_without_ine_is_flagged() { let s = only("CREATE TABLE foo (id UUID PRIMARY KEY);"); assert!(is_create_table(&s)); assert!(!has_if_not_exists(&s)); } #[test] fn line_and_block_comments_are_stripped() { let s = statements("/* c */ CREATE INDEX i ON items (a); -- trailing\n"); assert_eq!(s.len(), 1); assert!(parse_create_index(&s[0]).is_some()); } }