mod analyze;
mod apply;
mod create;
mod down;
mod failpoint;
mod lock;
mod plan;
mod policy;
mod receipt;
mod reset;
mod risk;
mod rollback;
mod status;
pub mod types;
mod up;
mod verify;
#[cfg(feature = "watch")]
mod watch;
pub use analyze::migrate_analyze;
pub use apply::{ApplyPhase, MigrateApplyOptions, MigrateDirection, migrate_apply};
pub use create::migrate_create;
pub use down::migrate_down;
pub use failpoint::maybe_failpoint;
pub use lock::acquire_migration_lock;
pub use plan::migrate_plan;
pub use policy::{EnforcementMode, MigrationPolicy, ReceiptValidationMode, load_migration_policy};
pub use receipt::{
MigrationReceipt, ReceiptSignatureStatus, StoredMigrationReceipt,
ensure_migration_receipt_columns, now_epoch_ms, runtime_actor, runtime_git_sha,
verify_stored_receipt_signature, write_migration_receipt,
};
pub use reset::migrate_reset;
pub use rollback::migrate_rollback;
pub use status::migrate_status;
pub use up::{MigrateUpOptions, migrate_up};
#[cfg(feature = "watch")]
pub use watch::watch_schema;
use qail_core::ast::{Action, Constraint, Expr, Qail};
use qail_core::parser::schema::Schema;
use qail_core::transpiler::ToSql;
use qail_pg::PgDriver;
use std::path::{Path, PathBuf};
pub fn resolve_deltas_dir(create_if_missing: bool) -> anyhow::Result<PathBuf> {
if let Ok(content) = std::fs::read_to_string("qail.toml")
&& let Ok(config) = toml::from_str::<toml::Value>(&content)
&& let Some(dir) = config
.get("project")
.and_then(|p| p.get("migrations_dir"))
.and_then(|v| v.as_str())
{
let path = PathBuf::from(dir);
if path.exists() || create_if_missing {
if create_if_missing && !path.exists() {
std::fs::create_dir_all(&path)?;
}
return Ok(path);
}
}
let deltas = Path::new("deltas");
if deltas.exists() {
return Ok(deltas.to_path_buf());
}
if create_if_missing {
std::fs::create_dir_all(deltas)?;
return Ok(deltas.to_path_buf());
}
anyhow::bail!(
"No deltas/ directory found. Run 'qail init' first.\n\
Tip: Set a custom path in qail.toml:\n\
[project]\n\
migrations_dir = \"my_deltas\""
)
}
pub const MIGRATION_TABLE_SCHEMA: &str = r#"
table _qail_migrations (
id serial primary_key,
version varchar(255) not null unique,
name varchar(255),
applied_at timestamptz default NOW(),
checksum varchar(64) not null,
sql_up text not null,
sql_down text,
git_sha varchar(64),
qail_version varchar(32),
actor varchar(255),
started_at_ms bigint,
finished_at_ms bigint,
duration_ms bigint,
affected_rows_est bigint,
risk_summary text,
shadow_checksum varchar(64),
receipt_sig text
)
"#;
pub fn migration_table_ddl() -> String {
let Ok(schema) = Schema::parse(MIGRATION_TABLE_SCHEMA) else {
return String::new();
};
schema
.tables
.first()
.map(|table| table.to_ddl())
.unwrap_or_default()
}
pub fn stable_cmds_checksum(cmds: &[Qail]) -> String {
let mut material = String::new();
for cmd in cmds {
let sql = cmd.to_sql();
let ast = qail_core::wire::encode_cmd_text(cmd);
material.push_str("SQL:");
material.push_str(sql.trim());
material.push('\n');
material.push_str("AST:");
material.push_str(&ast);
material.push('\n');
}
crate::time::md5_hex(&material)
}
pub async fn ensure_migration_table(driver: &mut PgDriver) -> anyhow::Result<()> {
let exists_cmd = Qail::get("information_schema.tables")
.column("1")
.where_eq("table_schema", "public")
.where_eq("table_name", "_qail_migrations")
.limit(1);
let exists = driver.fetch_all(&exists_cmd).await?;
if exists.is_empty() {
let cmd = Qail {
action: Action::Make,
table: "_qail_migrations".to_string(),
columns: vec![
Expr::Def {
name: "id".to_string(),
data_type: "serial".to_string(),
constraints: vec![Constraint::PrimaryKey],
},
Expr::Def {
name: "version".to_string(),
data_type: "varchar".to_string(),
constraints: vec![Constraint::Unique],
},
Expr::Def {
name: "name".to_string(),
data_type: "varchar".to_string(),
constraints: vec![Constraint::Nullable],
},
Expr::Def {
name: "applied_at".to_string(),
data_type: "timestamptz".to_string(),
constraints: vec![
Constraint::Nullable,
Constraint::Default("now()".to_string()),
],
},
Expr::Def {
name: "checksum".to_string(),
data_type: "varchar".to_string(),
constraints: vec![],
},
Expr::Def {
name: "sql_up".to_string(),
data_type: "text".to_string(),
constraints: vec![],
},
Expr::Def {
name: "sql_down".to_string(),
data_type: "text".to_string(),
constraints: vec![Constraint::Nullable],
},
Expr::Def {
name: "git_sha".to_string(),
data_type: "varchar".to_string(),
constraints: vec![Constraint::Nullable],
},
Expr::Def {
name: "qail_version".to_string(),
data_type: "varchar".to_string(),
constraints: vec![Constraint::Nullable],
},
Expr::Def {
name: "actor".to_string(),
data_type: "varchar".to_string(),
constraints: vec![Constraint::Nullable],
},
Expr::Def {
name: "started_at_ms".to_string(),
data_type: "bigint".to_string(),
constraints: vec![Constraint::Nullable],
},
Expr::Def {
name: "finished_at_ms".to_string(),
data_type: "bigint".to_string(),
constraints: vec![Constraint::Nullable],
},
Expr::Def {
name: "duration_ms".to_string(),
data_type: "bigint".to_string(),
constraints: vec![Constraint::Nullable],
},
Expr::Def {
name: "affected_rows_est".to_string(),
data_type: "bigint".to_string(),
constraints: vec![Constraint::Nullable],
},
Expr::Def {
name: "risk_summary".to_string(),
data_type: "text".to_string(),
constraints: vec![Constraint::Nullable],
},
Expr::Def {
name: "shadow_checksum".to_string(),
data_type: "varchar".to_string(),
constraints: vec![Constraint::Nullable],
},
Expr::Def {
name: "receipt_sig".to_string(),
data_type: "text".to_string(),
constraints: vec![Constraint::Nullable],
},
],
..Default::default()
};
if let Err(create_err) = driver.execute(&cmd).await {
let exists_after = driver.fetch_all(&exists_cmd).await?;
if exists_after.is_empty() {
return Err(create_err.into());
}
}
}
ensure_migration_receipt_columns(driver).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::stable_cmds_checksum;
use qail_core::ast::{Action, Expr, IndexDef, Qail};
#[test]
fn stable_checksum_distinguishes_column_renames() {
let rename_a = Qail {
action: Action::Mod,
table: "users".to_string(),
columns: vec![Expr::Named("email -> email_address".to_string())],
..Default::default()
};
let rename_b = Qail {
action: Action::Mod,
table: "users".to_string(),
columns: vec![Expr::Named("email -> primary_email".to_string())],
..Default::default()
};
let a = stable_cmds_checksum(&[rename_a]);
let b = stable_cmds_checksum(&[rename_b]);
assert_ne!(a, b, "different renames must produce different checksums");
}
#[test]
fn stable_checksum_uses_index_def_table() {
let idx_users = Qail {
action: Action::Index,
table: String::new(),
index_def: Some(IndexDef {
name: "idx_lookup".to_string(),
table: "users".to_string(),
columns: vec!["email".to_string()],
unique: false,
index_type: None,
where_clause: None,
}),
..Default::default()
};
let idx_orgs = Qail {
action: Action::Index,
table: String::new(),
index_def: Some(IndexDef {
name: "idx_lookup".to_string(),
table: "organizations".to_string(),
columns: vec!["email".to_string()],
unique: false,
index_type: None,
where_clause: None,
}),
..Default::default()
};
let users = stable_cmds_checksum(&[idx_users]);
let orgs = stable_cmds_checksum(&[idx_orgs]);
assert_ne!(
users, orgs,
"index checksums must differ when target tables differ"
);
}
}