use std::path::{Path, PathBuf};
use std::process::Command;
const SECRET_PW: &str = "s3cr3t_pw_do_not_leak";
const fn autumn_bin() -> &'static str {
env!("CARGO_BIN_EXE_autumn")
}
fn run_autumn(dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> (String, String, Option<i32>) {
let output = Command::new(autumn_bin())
.args(args)
.current_dir(dir)
.envs(envs.iter().copied())
.output()
.expect("failed to run autumn");
(
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
output.status.code(),
)
}
fn run_autumn_ok(dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> (String, String) {
let (stdout, stderr, code) = run_autumn(dir, args, envs);
assert_eq!(
code,
Some(0),
"autumn {args:?} failed (exit={code:?})\nstdout: {stdout}\nstderr: {stderr}",
);
(stdout, stderr)
}
fn run_autumn_fail(dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> (String, String) {
let (stdout, stderr, code) = run_autumn(dir, args, envs);
assert_ne!(
code,
Some(0),
"autumn {args:?} should have failed but exited 0\nstdout: {stdout}\nstderr: {stderr}",
);
(stdout, stderr)
}
fn fresh_project(name: &str) -> (tempfile::TempDir, PathBuf) {
let tmp = tempfile::tempdir().expect("tempdir");
run_autumn_ok(tmp.path(), &["new", name], &[]);
let project = tmp.path().join(name);
(tmp, project)
}
fn write_models(project: &Path, content: &str) {
let src = project.join("src");
let _ = std::fs::remove_dir_all(src.join("models"));
std::fs::write(src.join("models.rs"), content).expect("write models.rs");
}
fn write_raw_migration(project: &Path, name: &str, up_sql: &str, down_sql: &str) {
let dir = project.join("migrations").join(name);
std::fs::create_dir_all(&dir).expect("mkdir migration");
std::fs::write(dir.join("up.sql"), up_sql).expect("up.sql");
std::fs::write(dir.join("down.sql"), down_sql).expect("down.sql");
}
async fn start_postgres() -> (
testcontainers::ContainerAsync<testcontainers_modules::postgres::Postgres>,
String,
u16,
) {
use testcontainers::ImageExt as _;
use testcontainers::runners::AsyncRunner as _;
use testcontainers_modules::postgres::Postgres;
let container = Postgres::default()
.with_password(SECRET_PW)
.with_tag("16-alpine")
.start()
.await
.expect("failed to start Postgres testcontainer — is Docker running?");
let host = container.get_host().await.unwrap().to_string();
let port = container.get_host_port_ipv4(5432).await.unwrap();
(container, host, port)
}
fn assert_no_secret_leak(stdout: &str, stderr: &str) {
assert!(
!stdout.contains(SECRET_PW) && !stderr.contains(SECRET_PW),
"credentials leaked!\nstdout: {stdout}\nstderr: {stderr}"
);
}
const ROUND_TRIP_MODELS: &str = r"
#[autumn_web::model(managed)]
pub struct Author {
#[id]
pub id: i64,
#[unique]
pub email: String,
pub name: String,
#[default]
pub created_at: chrono::NaiveDateTime,
}
#[autumn_web::model(managed)]
pub struct Post {
#[id]
pub id: i64,
#[references]
pub author_id: i64,
pub title: String,
pub views: i32,
pub published: bool,
pub rating: rust_decimal::Decimal,
pub metadata: autumn_web::storage::Blob,
pub launched_at: chrono::DateTime<chrono::Utc>,
#[default]
pub created_at: chrono::NaiveDateTime,
}
#[autumn_web::model(managed)]
pub struct Session {
#[id]
pub id: uuid::Uuid,
pub label: String,
pub weight: f64,
pub note: Option<String>,
#[default]
pub created_at: chrono::NaiveDateTime,
}
";
fn build_schema_into_db(project: &Path, envs: &[(&str, &str)]) {
std::fs::write(project.join("empty_models.rs"), "").expect("empty models");
run_autumn_ok(
project,
&[
"schema",
"snapshot",
"--from",
"empty_models.rs",
"--backend",
"pg",
],
envs,
);
run_autumn_ok(
project,
&[
"schema",
"diff",
"--write-migration",
"--name",
"create_schema",
],
envs,
);
run_autumn_ok(project, &["schema", "migrate"], envs);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers); run with -- --ignored"]
#[allow(clippy::too_many_lines)] async fn schema_pull_round_trips_models_through_the_database() {
let (_container, host, port) = start_postgres().await;
let url = format!("postgres://postgres:{SECRET_PW}@{host}:{port}/postgres");
let envs = [("AUTUMN_DATABASE__URL", url.as_str())];
let (_tmp, project) = fresh_project("pull_roundtrip_app");
let snapshot_path = project.join(".autumn/schema-snapshot.json");
write_models(&project, ROUND_TRIP_MODELS);
build_schema_into_db(&project, &envs);
let (pull_out, pull_err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert!(
pull_out.contains("pulled schema snapshot") && pull_out.contains("3 table(s)"),
"pull reports the table count: {pull_out}"
);
assert_no_secret_leak(&pull_out, &pull_err);
let snap = std::fs::read_to_string(&snapshot_path).expect("pulled snapshot");
assert!(
snap.contains("\"name\": \"authors\""),
"authors table: {snap}"
);
assert!(snap.contains("\"name\": \"posts\""), "posts table: {snap}");
assert!(
snap.contains("\"name\": \"author_id\""),
"fk column present"
);
assert!(
snap.contains("\"table\": \"authors\""),
"the FK references authors: {snap}"
);
assert!(
snap.contains("idx_authors_email_unique"),
"unique index: {snap}"
);
assert!(
snap.contains("idx_posts_author_id"),
"fk auto-index: {snap}"
);
assert!(
snap.contains("\"Decimal\""),
"decimal type preserved: {snap}"
);
assert!(snap.contains("\"Attachment\""), "jsonb→Attachment: {snap}");
assert!(
snap.contains("\"TimestampTz\""),
"timestamptz preserved: {snap}"
);
assert!(
snap.contains("\"name\": \"sessions\""),
"sessions table: {snap}"
);
assert!(snap.contains("\"Uuid\""), "uuid PK type preserved: {snap}");
assert!(
snap.contains("gen_random_uuid()"),
"uuid PK default preserved: {snap}"
);
assert!(
snap.contains("\"Float64\""),
"float column preserved: {snap}"
);
assert!(
!snap.contains("__diesel_schema_migrations"),
"no diesel table"
);
let (diff_out, diff_err) = run_autumn_ok(&project, &["schema", "diff"], &envs);
assert!(
diff_out.contains("No schema changes"),
"round-trip must yield an empty diff:\n{diff_out}"
);
assert_no_secret_leak(&diff_out, &diff_err);
let (doc_out, doc_err) = run_autumn_ok(&project, &["schema", "doctor"], &envs);
assert!(
doc_out.contains("database-schema-drift")
&& doc_out.contains("database schema matches the snapshot baseline"),
"doctor reports the DB matches the snapshot:\n{doc_out}"
);
assert_no_secret_leak(&doc_out, &doc_err);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers); run with -- --ignored"]
async fn schema_pull_preserves_unmappable_types_as_opaque() {
let (_container, host, port) = start_postgres().await;
let url = format!("postgres://postgres:{SECRET_PW}@{host}:{port}/postgres");
let envs = [("AUTUMN_DATABASE__URL", url.as_str())];
let (_tmp, project) = fresh_project("pull_opaque_app");
let snapshot_path = project.join(".autumn/schema-snapshot.json");
write_models(&project, "");
write_raw_migration(
&project,
"20260101000000_create_gadgets",
"CREATE TABLE gadgets (id BIGSERIAL PRIMARY KEY, addr inet NOT NULL);",
"DROP TABLE gadgets;",
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
let (out, err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&out, &err);
let snap = std::fs::read_to_string(&snapshot_path).expect("pulled snapshot");
assert!(
snap.contains("\"name\": \"gadgets\""),
"gadgets pulled: {snap}"
);
assert!(
snap.contains("\"name\": \"addr\""),
"opaque column present: {snap}"
);
assert!(
snap.contains("\"Opaque\""),
"opaque variant emitted: {snap}"
);
assert!(
snap.contains("\"pg_type\": \"inet\""),
"raw pg type preserved verbatim: {snap}"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers); run with -- --ignored"]
async fn schema_doctor_reports_database_schema_drift() {
let (_container, host, port) = start_postgres().await;
let url = format!("postgres://postgres:{SECRET_PW}@{host}:{port}/postgres");
let envs = [("AUTUMN_DATABASE__URL", url.as_str())];
let (_tmp, project) = fresh_project("pull_doctor_drift_app");
write_models(
&project,
"#[autumn_web::model(managed)]\npub struct Post {\n #[id]\n pub id: i64,\n pub title: String,\n}\n",
);
build_schema_into_db(&project, &envs);
let (clean_out, _) = run_autumn_ok(&project, &["schema", "doctor"], &envs);
assert!(
clean_out.contains("database schema matches the snapshot baseline"),
"doctor is clean before the out-of-band change:\n{clean_out}"
);
write_raw_migration(
&project,
"20990101000000_add_extra",
"ALTER TABLE posts ADD COLUMN extra TEXT;",
"ALTER TABLE posts DROP COLUMN extra;",
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
let (drift_stdout, drift_stderr) = run_autumn_ok(&project, &["schema", "doctor"], &envs);
assert!(
drift_stdout.contains("database-schema-drift") && drift_stdout.contains("[WARN]"),
"doctor reports DB drift as a WARN:\n{drift_stdout}"
);
assert!(
drift_stdout.contains("differs from the snapshot")
&& drift_stdout.contains("autumn schema pull"),
"the drift detail names the remediation:\n{drift_stdout}"
);
assert_no_secret_leak(&drift_stdout, &drift_stderr);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers); run with -- --ignored"]
async fn schema_pull_dry_run_reports_diff_without_writing() {
let (_container, host, port) = start_postgres().await;
let url = format!("postgres://postgres:{SECRET_PW}@{host}:{port}/postgres");
let envs = [("AUTUMN_DATABASE__URL", url.as_str())];
let (_tmp, project) = fresh_project("pull_dry_run_app");
let snapshot_path = project.join(".autumn/schema-snapshot.json");
write_models(
&project,
"#[autumn_web::model(managed)]\npub struct Post {\n #[id]\n pub id: i64,\n pub title: String,\n}\n",
);
build_schema_into_db(&project, &envs);
run_autumn_ok(&project, &["schema", "pull"], &envs);
let snap_before = std::fs::read_to_string(&snapshot_path).expect("snapshot before dry-run");
write_raw_migration(
&project,
"20990202000000_add_note",
"ALTER TABLE posts ADD COLUMN note TEXT;",
"ALTER TABLE posts DROP COLUMN note;",
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
let (dry_out, dry_err) = run_autumn_ok(&project, &["schema", "pull", "--dry-run"], &envs);
assert!(
dry_out.contains("--dry-run") && dry_out.contains("differs from the snapshot"),
"dry-run reports the diff:\n{dry_out}"
);
assert_no_secret_leak(&dry_out, &dry_err);
let snap_after = std::fs::read_to_string(&snapshot_path).expect("snapshot after dry-run");
assert_eq!(
snap_before, snap_after,
"a --dry-run pull must leave the snapshot byte-for-byte unchanged"
);
assert!(
!snap_after.contains("note"),
"the out-of-band `note` column must NOT be written by a dry-run: {snap_after}"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers); run with -- --ignored"]
async fn schema_pull_preserves_expression_and_partial_indexes() {
let (_container, host, port) = start_postgres().await;
let url = format!("postgres://postgres:{SECRET_PW}@{host}:{port}/postgres");
let envs = [("AUTUMN_DATABASE__URL", url.as_str())];
let (_tmp, project) = fresh_project("pull_expr_index_app");
let snapshot_path = project.join(".autumn/schema-snapshot.json");
write_models(&project, "");
write_raw_migration(
&project,
"20260101000000_create_members",
"CREATE TABLE members (id BIGSERIAL PRIMARY KEY, email TEXT NOT NULL, deleted_at TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT NOW());\n\
CREATE INDEX members_lower_email_idx ON members (lower(email));\n\
CREATE INDEX members_active_email_idx ON members (email) WHERE deleted_at IS NULL;",
"DROP TABLE members;",
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
let (out, err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&out, &err);
let snap = std::fs::read_to_string(&snapshot_path).expect("pulled snapshot");
assert!(
snap.contains("members_lower_email_idx"),
"expression index preserved by name: {snap}"
);
assert!(
snap.contains("lower(email)"),
"expression index definition preserved verbatim: {snap}"
);
assert!(
snap.contains("members_active_email_idx"),
"partial index preserved by name: {snap}"
);
assert!(
snap.contains("WHERE") && snap.contains("deleted_at IS NULL"),
"partial index predicate preserved verbatim: {snap}"
);
assert!(
snap.contains("\"definition\""),
"the raw definition field is serialized for non-representable indexes: {snap}"
);
let snap_before = snap;
let (pull2_out, pull2_err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&pull2_out, &pull2_err);
let snap_after = std::fs::read_to_string(&snapshot_path).expect("snapshot after second pull");
assert_eq!(
snap_before, snap_after,
"a re-pull of the same database must be byte-for-byte stable (no expression/partial drift)"
);
let (doc_out, doc_err) = run_autumn_ok(&project, &["schema", "doctor"], &envs);
assert!(
doc_out.contains("database schema matches the snapshot baseline"),
"doctor reports the DB matches the snapshot with expression/partial indexes:\n{doc_out}"
);
assert_no_secret_leak(&doc_out, &doc_err);
write_models(
&project,
"
#[autumn_web::model(managed)]
pub struct Member {
#[id]
pub id: i64,
pub email: String,
pub deleted_at: Option<chrono::NaiveDateTime>,
}
",
);
let (mdiff_out, mdiff_err) = run_autumn_ok(&project, &["schema", "diff"], &envs);
assert!(
!mdiff_out.contains("DROP INDEX")
&& !mdiff_out.contains("members_lower_email_idx")
&& !mdiff_out.contains("members_active_email_idx"),
"a model-side `schema diff` must NOT drop the unmodellable expression/partial indexes:\n{mdiff_out}"
);
assert!(
mdiff_out.contains("No schema changes"),
"model diff against the pulled snapshot is clean (definition indexes retained):\n{mdiff_out}"
);
assert_no_secret_leak(&mdiff_out, &mdiff_err);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers); run with -- --ignored"]
async fn schema_pull_retains_constraint_owned_unique_index() {
let (_container, host, port) = start_postgres().await;
let url = format!("postgres://postgres:{SECRET_PW}@{host}:{port}/postgres");
let envs = [("AUTUMN_DATABASE__URL", url.as_str())];
let (_tmp, project) = fresh_project("pull_constraint_index_app");
let snapshot_path = project.join(".autumn/schema-snapshot.json");
write_models(&project, "");
write_raw_migration(
&project,
"20260101000000_create_accounts",
"CREATE TABLE accounts (id BIGSERIAL PRIMARY KEY, email TEXT NOT NULL UNIQUE, created_at TIMESTAMP NOT NULL DEFAULT NOW());",
"DROP TABLE accounts;",
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
let (out, err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&out, &err);
let snap = std::fs::read_to_string(&snapshot_path).expect("pulled snapshot");
assert!(
snap.contains("accounts_email_key"),
"constraint-backed index preserved by its Postgres-assigned name: {snap}"
);
assert!(
snap.contains("\"definition\"") && snap.contains("CREATE UNIQUE INDEX"),
"the constraint-owned index is retained via its verbatim CREATE UNIQUE INDEX definition: {snap}"
);
let (doc_out, doc_err) = run_autumn_ok(&project, &["schema", "doctor"], &envs);
assert!(
doc_out.contains("database schema matches the snapshot baseline"),
"doctor reports the DB matches the snapshot with the constraint-owned index:\n{doc_out}"
);
assert_no_secret_leak(&doc_out, &doc_err);
write_models(
&project,
"
#[autumn_web::model(managed)]
pub struct Account {
#[id]
pub id: i64,
pub email: String,
}
",
);
let (mdiff_out, mdiff_err) = run_autumn_ok(&project, &["schema", "diff"], &envs);
assert!(
!mdiff_out.contains("DROP INDEX") && !mdiff_out.contains("accounts_email_key"),
"a model-side `schema diff` must NOT drop the constraint-owned index:\n{mdiff_out}"
);
assert!(
mdiff_out.contains("No schema changes"),
"model diff against the pulled snapshot is clean (constraint-owned index retained):\n{mdiff_out}"
);
assert_no_secret_leak(&mdiff_out, &mdiff_err);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers); run with -- --ignored"]
async fn schema_pull_retains_exclude_constraint_as_real_constraint() {
let (_container, host, port) = start_postgres().await;
let url = format!("postgres://postgres:{SECRET_PW}@{host}:{port}/postgres");
let envs = [("AUTUMN_DATABASE__URL", url.as_str())];
let (_tmp, project) = fresh_project("pull_exclude_constraint_app");
let snapshot_path = project.join(".autumn/schema-snapshot.json");
write_models(&project, "");
write_raw_migration(
&project,
"20260101000000_create_reservations",
"CREATE TABLE reservations (id BIGSERIAL PRIMARY KEY, during tstzrange NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW(), EXCLUDE USING gist (during WITH &&));",
"DROP TABLE reservations;",
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
let (out, err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&out, &err);
let snap = std::fs::read_to_string(&snapshot_path).expect("pulled snapshot");
assert!(
snap.contains("EXCLUDE USING gist"),
"the EXCLUDE constraint is retained via its ADD CONSTRAINT … EXCLUDE USING … definition: {snap}"
);
assert!(
snap.contains("ADD CONSTRAINT"),
"the retained definition is the real constraint recreation (ALTER TABLE … ADD CONSTRAINT): {snap}"
);
assert!(
snap.contains("\"definition\""),
"the raw definition field is serialized for the EXCLUDE constraint index: {snap}"
);
let snap_before = snap;
let (pull2_out, pull2_err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&pull2_out, &pull2_err);
let snap_after = std::fs::read_to_string(&snapshot_path).expect("snapshot after second pull");
assert_eq!(
snap_before, snap_after,
"a re-pull of the same database must be byte-for-byte stable (no EXCLUDE-constraint drift)"
);
let (doc_out, doc_err) = run_autumn_ok(&project, &["schema", "doctor"], &envs);
assert!(
doc_out.contains("database schema matches the snapshot baseline"),
"doctor reports the DB matches the snapshot with the EXCLUDE constraint:\n{doc_out}"
);
assert_no_secret_leak(&doc_out, &doc_err);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers); run with -- --ignored"]
async fn schema_diff_model_unique_matches_pulled_constraint_index() {
let (_container, host, port) = start_postgres().await;
let url = format!("postgres://postgres:{SECRET_PW}@{host}:{port}/postgres");
let envs = [("AUTUMN_DATABASE__URL", url.as_str())];
let (_tmp, project) = fresh_project("pull_model_unique_app");
let snapshot_path = project.join(".autumn/schema-snapshot.json");
write_models(&project, "");
write_raw_migration(
&project,
"20260101000000_create_accounts",
"CREATE TABLE accounts (id BIGSERIAL PRIMARY KEY, email TEXT NOT NULL UNIQUE, created_at TIMESTAMP NOT NULL DEFAULT NOW());",
"DROP TABLE accounts;",
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
let (out, err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&out, &err);
let snap = std::fs::read_to_string(&snapshot_path).expect("pulled snapshot");
assert!(
snap.contains("accounts_email_key"),
"constraint-backed index retained by its Postgres-assigned name: {snap}"
);
write_models(
&project,
"
#[autumn_web::model(managed)]
pub struct Account {
#[id]
pub id: i64,
#[unique]
pub email: String,
}
",
);
let (mdiff_out, mdiff_err) = run_autumn_ok(&project, &["schema", "diff"], &envs);
assert!(
!mdiff_out.contains("AddIndex") && !mdiff_out.contains("CREATE UNIQUE INDEX"),
"a model #[unique] over a brownfield-UNIQUE column must NOT emit AddIndex:\n{mdiff_out}"
);
assert!(
!mdiff_out.contains("DROP INDEX") && !mdiff_out.contains("accounts_email_key"),
"the retained constraint-owned index must NOT be dropped:\n{mdiff_out}"
);
assert!(
mdiff_out.contains("No schema changes"),
"model diff with #[unique] against the pulled unique constraint is clean:\n{mdiff_out}"
);
assert_no_secret_leak(&mdiff_out, &mdiff_err);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers); run with -- --ignored"]
async fn schema_pull_retains_covering_include_index() {
let (_container, host, port) = start_postgres().await;
let url = format!("postgres://postgres:{SECRET_PW}@{host}:{port}/postgres");
let envs = [("AUTUMN_DATABASE__URL", url.as_str())];
let (_tmp, project) = fresh_project("pull_include_index_app");
let snapshot_path = project.join(".autumn/schema-snapshot.json");
write_models(&project, "");
write_raw_migration(
&project,
"20260101000000_create_covers",
"CREATE TABLE covers (id BIGSERIAL PRIMARY KEY, a TEXT NOT NULL, b TEXT, created_at TIMESTAMP NOT NULL DEFAULT NOW());\n\
CREATE UNIQUE INDEX covers_a_inc_b ON covers (a) INCLUDE (b);",
"DROP TABLE covers;",
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
let (out, err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&out, &err);
let snap = std::fs::read_to_string(&snapshot_path).expect("pulled snapshot");
assert!(
snap.contains("covers_a_inc_b"),
"the covering index is preserved by name: {snap}"
);
assert!(
snap.contains("\"definition\"") && snap.contains("INCLUDE"),
"the covering index is retained via its verbatim CREATE UNIQUE INDEX … INCLUDE … definition: {snap}"
);
let (doc_out, doc_err) = run_autumn_ok(&project, &["schema", "doctor"], &envs);
assert!(
doc_out.contains("database schema matches the snapshot baseline"),
"doctor reports the DB matches the snapshot with the covering index:\n{doc_out}"
);
assert_no_secret_leak(&doc_out, &doc_err);
write_models(
&project,
"
#[autumn_web::model(managed)]
pub struct Cover {
#[id]
pub id: i64,
pub a: String,
pub b: Option<String>,
}
",
);
let (mdiff_out, mdiff_err) = run_autumn_ok(&project, &["schema", "diff"], &envs);
assert!(
!mdiff_out.contains("covers_a_inc_b"),
"a model-side `schema diff` must NOT recreate or drop the covering index:\n{mdiff_out}"
);
assert!(
mdiff_out.contains("No schema changes"),
"model diff against the pulled snapshot is clean (covering index retained):\n{mdiff_out}"
);
assert_no_secret_leak(&mdiff_out, &mdiff_err);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers); run with -- --ignored"]
async fn schema_diff_dropping_uniquely_indexed_column_stays_clean() {
let (_container, host, port) = start_postgres().await;
let url = format!("postgres://postgres:{SECRET_PW}@{host}:{port}/postgres");
let envs = [("AUTUMN_DATABASE__URL", url.as_str())];
let (_tmp, project) = fresh_project("pull_drop_unique_col_app");
write_models(&project, "");
write_raw_migration(
&project,
"20260101000000_create_accounts",
"CREATE TABLE accounts (id BIGSERIAL PRIMARY KEY, email TEXT NOT NULL UNIQUE, note TEXT);",
"DROP TABLE accounts;",
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
run_autumn_ok(&project, &["schema", "pull"], &envs);
write_models(
&project,
"
#[autumn_web::model(managed)]
pub struct Account {
#[id]
pub id: i64,
pub note: Option<String>,
}
",
);
let (diff_out, diff_err) = run_autumn_ok(
&project,
&[
"schema",
"diff",
"--write-migration",
"--name",
"drop_email",
"--allow-destructive",
],
&envs,
);
assert_no_secret_leak(&diff_out, &diff_err);
let mig_root = project.join("migrations");
let mut up_sql = String::new();
let mut down_sql = String::new();
for entry in std::fs::read_dir(&mig_root).expect("migrations dir") {
let dir = entry.expect("entry").path();
if dir
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains("drop_email"))
{
up_sql = std::fs::read_to_string(dir.join("up.sql")).unwrap_or_default();
down_sql = std::fs::read_to_string(dir.join("down.sql")).unwrap_or_default();
}
}
assert!(
up_sql.contains("DROP COLUMN email"),
"up migration drops the column: {up_sql}"
);
assert!(
!up_sql.contains("DROP INDEX"),
"up migration must NOT emit a DROP INDEX for the cascade-dropped constraint index: {up_sql}"
);
assert!(
down_sql.contains("CREATE UNIQUE INDEX") && down_sql.contains("accounts_email_key"),
"down migration restores the cascade-dropped retained index: {down_sql}"
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
let (doc_out, doc_err) = run_autumn_ok(&project, &["schema", "doctor"], &envs);
assert!(
doc_out.contains("database schema matches the snapshot baseline"),
"doctor reports no drift after dropping the uniquely-indexed column:\n{doc_out}"
);
assert_no_secret_leak(&doc_out, &doc_err);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers); run with -- --ignored"]
async fn schema_diff_dropping_column_named_in_index_predicate_literal_stays_clean() {
let (_container, host, port) = start_postgres().await;
let url = format!("postgres://postgres:{SECRET_PW}@{host}:{port}/postgres");
let envs = [("AUTUMN_DATABASE__URL", url.as_str())];
let (_tmp, project) = fresh_project("pull_predicate_literal_app");
write_models(&project, "");
write_raw_migration(
&project,
"20260101000000_create_events",
"CREATE TABLE events (id BIGSERIAL PRIMARY KEY, kind TEXT, email TEXT, created_at TIMESTAMP NOT NULL DEFAULT NOW());\n\
CREATE INDEX events_id_when_email_idx ON events (id) WHERE kind = 'email';",
"DROP TABLE events;",
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
run_autumn_ok(&project, &["schema", "pull"], &envs);
write_models(
&project,
"
#[autumn_web::model(managed)]
pub struct Event {
#[id]
pub id: i64,
pub kind: Option<String>,
}
",
);
let (diff_out, diff_err) = run_autumn_ok(
&project,
&[
"schema",
"diff",
"--write-migration",
"--name",
"drop_email",
"--allow-destructive",
],
&envs,
);
assert_no_secret_leak(&diff_out, &diff_err);
let mig_root = project.join("migrations");
let mut up_sql = String::new();
let mut down_sql = String::new();
for entry in std::fs::read_dir(&mig_root).expect("migrations dir") {
let dir = entry.expect("entry").path();
if dir
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains("drop_email"))
{
up_sql = std::fs::read_to_string(dir.join("up.sql")).unwrap_or_default();
down_sql = std::fs::read_to_string(dir.join("down.sql")).unwrap_or_default();
}
}
assert!(
up_sql.contains("DROP COLUMN email"),
"up migration drops the column: {up_sql}"
);
assert!(
!down_sql.contains("events_id_when_email_idx"),
"down migration must NOT recreate an index Postgres never dropped: {down_sql}"
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
let (doc_out, doc_err) = run_autumn_ok(&project, &["schema", "doctor"], &envs);
assert!(
doc_out.contains("database schema matches the snapshot baseline"),
"doctor reports no drift — the partial index was not falsely pruned:\n{doc_out}"
);
assert_no_secret_leak(&doc_out, &doc_err);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers); run with -- --ignored"]
async fn schema_diff_dropping_two_indexed_columns_recreates_index_once() {
let (_container, host, port) = start_postgres().await;
let url = format!("postgres://postgres:{SECRET_PW}@{host}:{port}/postgres");
let envs = [("AUTUMN_DATABASE__URL", url.as_str())];
let (_tmp, project) = fresh_project("pull_drop_two_indexed_cols_app");
write_models(&project, "");
write_raw_migration(
&project,
"20260101000000_create_members",
"CREATE TABLE members (id BIGSERIAL PRIMARY KEY, email TEXT NOT NULL, tenant_id BIGINT, created_at TIMESTAMP NOT NULL DEFAULT NOW());\n\
CREATE INDEX members_active_idx ON members (lower(email)) WHERE tenant_id IS NOT NULL;",
"DROP TABLE members;",
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
run_autumn_ok(&project, &["schema", "pull"], &envs);
write_models(
&project,
"
#[autumn_web::model(managed)]
pub struct Member {
#[id]
pub id: i64,
}
",
);
let (diff_out, diff_err) = run_autumn_ok(
&project,
&[
"schema",
"diff",
"--write-migration",
"--name",
"drop_both",
"--allow-destructive",
],
&envs,
);
assert_no_secret_leak(&diff_out, &diff_err);
let mig_root = project.join("migrations");
let mut up_sql = String::new();
let mut down_sql = String::new();
for entry in std::fs::read_dir(&mig_root).expect("migrations dir") {
let dir = entry.expect("entry").path();
if dir
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains("drop_both"))
{
up_sql = std::fs::read_to_string(dir.join("up.sql")).unwrap_or_default();
down_sql = std::fs::read_to_string(dir.join("down.sql")).unwrap_or_default();
}
}
assert!(
up_sql.contains("DROP COLUMN email") && up_sql.contains("DROP COLUMN tenant_id"),
"up migration drops both columns: {up_sql}"
);
assert!(
!up_sql.contains("DROP INDEX"),
"up migration must NOT emit a DROP INDEX for the cascade-dropped index: {up_sql}"
);
assert!(
down_sql.contains("ADD COLUMN email") && down_sql.contains("ADD COLUMN tenant_id"),
"down migration re-adds both dependent columns: {down_sql}"
);
assert_eq!(
down_sql.matches("members_active_idx").count(),
1,
"the multi-column retained index must be recreated exactly once (deduped): {down_sql}"
);
let email_at = down_sql.find("ADD COLUMN email").expect("email re-add");
let tenant_at = down_sql
.find("ADD COLUMN tenant_id")
.expect("tenant_id re-add");
let index_at = down_sql
.find("members_active_idx")
.expect("index recreation");
assert!(
email_at < index_at && tenant_at < index_at,
"both dependent columns must be re-added BEFORE the index recreation: {down_sql}"
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
let (doc_out, doc_err) = run_autumn_ok(&project, &["schema", "doctor"], &envs);
assert!(
doc_out.contains("database schema matches the snapshot baseline"),
"doctor reports no drift after dropping both indexed columns:\n{doc_out}"
);
assert_no_secret_leak(&doc_out, &doc_err);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers); run with -- --ignored"]
async fn schema_pull_preserves_int4_serial_primary_key() {
let (_container, host, port) = start_postgres().await;
let url = format!("postgres://postgres:{SECRET_PW}@{host}:{port}/postgres");
let envs = [("AUTUMN_DATABASE__URL", url.as_str())];
let (_tmp, project) = fresh_project("pull_int4_serial_app");
let snapshot_path = project.join(".autumn/schema-snapshot.json");
write_models(&project, "");
write_raw_migration(
&project,
"20260101000000_create_counters",
"CREATE TABLE counters (id SERIAL PRIMARY KEY, label TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW());",
"DROP TABLE counters;",
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
let (out, err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&out, &err);
let snap = std::fs::read_to_string(&snapshot_path).expect("pulled snapshot");
assert!(
snap.contains("counters"),
"the brownfield table is captured: {snap}"
);
assert!(
snap.contains("nextval"),
"the int4 SERIAL PK's nextval default is PRESERVED (not stripped): {snap}"
);
let snap_before = snap;
let (pull2_out, pull2_err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&pull2_out, &pull2_err);
let snap_after = std::fs::read_to_string(&snapshot_path).expect("snapshot after second pull");
assert_eq!(
snap_before, snap_after,
"a re-pull of the same database must be byte-for-byte stable (int4 serial faithful)"
);
let (doc_out, doc_err) = run_autumn_ok(&project, &["schema", "doctor"], &envs);
assert!(
doc_out.contains("database schema matches the snapshot baseline"),
"doctor reports the DB matches the snapshot with the int4 SERIAL PK:\n{doc_out}"
);
assert_no_secret_leak(&doc_out, &doc_err);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers); run with -- --ignored"]
async fn schema_pull_preserves_shared_sequence_int8_primary_key() {
let (_container, host, port) = start_postgres().await;
let url = format!("postgres://postgres:{SECRET_PW}@{host}:{port}/postgres");
let envs = [("AUTUMN_DATABASE__URL", url.as_str())];
let (_tmp, project) = fresh_project("pull_shared_seq_app");
let snapshot_path = project.join(".autumn/schema-snapshot.json");
write_models(&project, "");
write_raw_migration(
&project,
"20260101000000_create_events",
"CREATE SEQUENCE global_ids;\n\
CREATE TABLE events (id BIGINT PRIMARY KEY DEFAULT nextval('global_ids'), label TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW());",
"DROP TABLE events;\nDROP SEQUENCE global_ids;",
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
let (out, err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&out, &err);
let snap = std::fs::read_to_string(&snapshot_path).expect("pulled snapshot");
assert!(
snap.contains("events"),
"the brownfield table is captured: {snap}"
);
assert!(
snap.contains("global_ids"),
"the shared-sequence int8 PK's nextval('global_ids') default is PRESERVED \
(not stripped, not re-emitted as BIGSERIAL): {snap}"
);
let snap_before = snap;
let (pull2_out, pull2_err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&pull2_out, &pull2_err);
let snap_after = std::fs::read_to_string(&snapshot_path).expect("snapshot after second pull");
assert_eq!(
snap_before, snap_after,
"a re-pull of the same database must be byte-for-byte stable (shared-sequence faithful)"
);
let (doc_out, doc_err) = run_autumn_ok(&project, &["schema", "doctor"], &envs);
assert!(
doc_out.contains("database schema matches the snapshot baseline"),
"doctor reports the DB matches the snapshot with the shared-sequence int8 PK:\n{doc_out}"
);
assert_no_secret_leak(&doc_out, &doc_err);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers); run with -- --ignored"]
async fn schema_pull_preserves_repointed_shared_sequence_default() {
let (_container, host, port) = start_postgres().await;
let url = format!("postgres://postgres:{SECRET_PW}@{host}:{port}/postgres");
let envs = [("AUTUMN_DATABASE__URL", url.as_str())];
let (_tmp, project) = fresh_project("pull_repointed_seq_app");
let snapshot_path = project.join(".autumn/schema-snapshot.json");
write_models(&project, "");
write_raw_migration(
&project,
"20260101000000_repoint",
"CREATE TABLE events (id BIGSERIAL PRIMARY KEY, label TEXT NOT NULL);\n\
CREATE SEQUENCE global_ids;\n\
ALTER TABLE events ALTER COLUMN id SET DEFAULT nextval('global_ids');",
"DROP TABLE events;\nDROP SEQUENCE global_ids;",
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
let (out, err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&out, &err);
let snap = std::fs::read_to_string(&snapshot_path).expect("pulled snapshot");
let tables = snapshot_columns(&snap);
let id = column_json(&tables["events"], "id");
assert!(
snap.contains("global_ids"),
"the repointed nextval('global_ids') default is preserved (not stripped): {snap}"
);
let default_sql = id["default"]["Sql"].as_str().unwrap_or_default();
assert!(
default_sql.contains("global_ids"),
"the id column keeps its nextval('global_ids') default: {id}"
);
assert_ne!(
id["serial"].as_str(),
Some("BigSerial"),
"a repointed-default PK is NOT a BigSerial: {id}"
);
let (pull2_out, pull2_err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&pull2_out, &pull2_err);
let snap_after = std::fs::read_to_string(&snapshot_path).expect("second pull");
assert_eq!(snap, snap_after, "a re-pull must be byte-for-byte stable");
let (doc_out, doc_err) = run_autumn_ok(&project, &["schema", "doctor"], &envs);
assert!(
doc_out.contains("database schema matches the snapshot baseline"),
"doctor is clean with the preserved repointed-sequence default:\n{doc_out}"
);
assert_no_secret_leak(&doc_out, &doc_err);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers); run with -- --ignored"]
async fn schema_pull_preserves_uuid_pk_without_convention_default() {
let (_container, host, port) = start_postgres().await;
let url = format!("postgres://postgres:{SECRET_PW}@{host}:{port}/postgres");
let envs = [("AUTUMN_DATABASE__URL", url.as_str())];
let (_tmp, project) = fresh_project("pull_uuid_no_default_app");
let snapshot_path = project.join(".autumn/schema-snapshot.json");
write_models(&project, "");
write_raw_migration(
&project,
"20260101000000_create_tokens",
"CREATE TABLE tokens (id uuid PRIMARY KEY, label TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW());",
"DROP TABLE tokens;",
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
let (out, err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&out, &err);
let snap = std::fs::read_to_string(&snapshot_path).expect("pulled snapshot");
assert!(
snap.contains("tokens"),
"the brownfield table is captured: {snap}"
);
assert!(
snap.contains("\"Uuid\""),
"the UUID PK type is preserved: {snap}"
);
assert!(
!snap.contains("gen_random_uuid()"),
"a non-convention UUID PK must NOT gain a gen_random_uuid() default on pull: {snap}"
);
let snap_before = snap;
let (pull2_out, pull2_err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&pull2_out, &pull2_err);
let snap_after = std::fs::read_to_string(&snapshot_path).expect("snapshot after second pull");
assert_eq!(
snap_before, snap_after,
"a re-pull of the same database must be byte-for-byte stable (UUID-no-default faithful)"
);
let (doc_out, doc_err) = run_autumn_ok(&project, &["schema", "doctor"], &envs);
assert!(
doc_out.contains("database schema matches the snapshot baseline"),
"doctor reports the DB matches the snapshot with the default-less UUID PK:\n{doc_out}"
);
assert_no_secret_leak(&doc_out, &doc_err);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers); run with -- --ignored"]
async fn schema_diff_partial_unique_index_does_not_satisfy_model_unique() {
let (_container, host, port) = start_postgres().await;
let url = format!("postgres://postgres:{SECRET_PW}@{host}:{port}/postgres");
let envs = [("AUTUMN_DATABASE__URL", url.as_str())];
let (_tmp, project) = fresh_project("pull_partial_unique_app");
let snapshot_path = project.join(".autumn/schema-snapshot.json");
write_models(&project, "");
write_raw_migration(
&project,
"20260101000000_create_accounts",
"CREATE TABLE accounts (id BIGSERIAL PRIMARY KEY, email TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW());\n\
CREATE UNIQUE INDEX accounts_email_active_key ON accounts (email) WHERE email <> '';",
"DROP TABLE accounts;",
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
let (out, err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&out, &err);
let snap = std::fs::read_to_string(&snapshot_path).expect("pulled snapshot");
assert!(
snap.contains("accounts_email_active_key"),
"the partial unique index is retained by name: {snap}"
);
assert!(
snap.contains("\"is_partial\"") && snap.contains("WHERE"),
"the pulled index is flagged partial and keeps its predicate: {snap}"
);
write_models(
&project,
"
#[autumn_web::model(managed)]
pub struct Account {
#[id]
pub id: i64,
#[unique]
pub email: String,
}
",
);
let (mdiff_out, mdiff_err) = run_autumn_fail(&project, &["schema", "diff"], &envs);
let combined = format!("{mdiff_out}{mdiff_err}");
assert!(
combined.contains("cannot add unique index") && combined.contains("accounts"),
"a partial unique index must NOT satisfy the model #[unique]; the diff must emit \
the unique AddIndex (guard-refused on the pre-existing column):\n{combined}"
);
assert_no_secret_leak(&mdiff_out, &mdiff_err);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers); run with -- --ignored"]
async fn schema_diff_same_named_partial_unique_index_does_not_satisfy_model_unique() {
let (_container, host, port) = start_postgres().await;
let url = format!("postgres://postgres:{SECRET_PW}@{host}:{port}/postgres");
let envs = [("AUTUMN_DATABASE__URL", url.as_str())];
let (_tmp, project) = fresh_project("pull_same_named_partial_unique_app");
let snapshot_path = project.join(".autumn/schema-snapshot.json");
write_models(&project, "");
write_raw_migration(
&project,
"20260101000000_create_members",
"CREATE TABLE members (id BIGSERIAL PRIMARY KEY, email TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW());\n\
CREATE UNIQUE INDEX idx_members_email_unique ON members (email) WHERE email IS NOT NULL;",
"DROP TABLE members;",
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
let (out, err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&out, &err);
let snap = std::fs::read_to_string(&snapshot_path).expect("pulled snapshot");
assert!(
snap.contains("idx_members_email_unique"),
"the conventionally-named partial unique index is retained by name: {snap}"
);
assert!(
snap.contains("\"is_partial\"") && snap.contains("WHERE"),
"the pulled index is flagged partial and keeps its predicate: {snap}"
);
write_models(
&project,
"
#[autumn_web::model(managed)]
pub struct Member {
#[id]
pub id: i64,
#[unique]
pub email: String,
}
",
);
let (mdiff_out, mdiff_err) = run_autumn_fail(&project, &["schema", "diff"], &envs);
let combined = format!("{mdiff_out}{mdiff_err}");
assert!(
combined.contains("cannot add unique index") && combined.contains("members"),
"a same-named partial unique index must NOT satisfy the model #[unique]; the diff \
must emit the unique AddIndex (guard-refused on the pre-existing column):\n{combined}"
);
assert_no_secret_leak(&mdiff_out, &mdiff_err);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers); run with -- --ignored"]
async fn schema_pull_preserves_domain_column_as_opaque() {
let (_container, host, port) = start_postgres().await;
let url = format!("postgres://postgres:{SECRET_PW}@{host}:{port}/postgres");
let envs = [("AUTUMN_DATABASE__URL", url.as_str())];
let (_tmp, project) = fresh_project("pull_domain_opaque_app");
let snapshot_path = project.join(".autumn/schema-snapshot.json");
write_models(&project, "");
write_raw_migration(
&project,
"20260101000000_create_contacts",
"CREATE DOMAIN email_dom AS text CHECK (VALUE LIKE '%@%');\n\
CREATE TABLE contacts (id BIGSERIAL PRIMARY KEY, email email_dom NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW());",
"DROP TABLE contacts; DROP DOMAIN email_dom;",
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
let (out, err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&out, &err);
let snap = std::fs::read_to_string(&snapshot_path).expect("pulled snapshot");
assert!(
snap.contains("\"name\": \"email\""),
"the domain column is present: {snap}"
);
assert!(
snap.contains("\"Opaque\"") && snap.contains("\"pg_type\": \"email_dom\""),
"the domain column is preserved as Opaque carrying the domain name (NOT Text): {snap}"
);
let snap_before = snap;
let (pull2_out, pull2_err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&pull2_out, &pull2_err);
let snap_after = std::fs::read_to_string(&snapshot_path).expect("snapshot after second pull");
assert_eq!(
snap_before, snap_after,
"a re-pull of the same database must be byte-for-byte stable (no domain drift)"
);
let (doc_out, doc_err) = run_autumn_ok(&project, &["schema", "doctor"], &envs);
assert!(
doc_out.contains("database schema matches the snapshot baseline"),
"doctor reports the DB matches the snapshot with the domain column:\n{doc_out}"
);
assert_no_secret_leak(&doc_out, &doc_err);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers); run with -- --ignored"]
async fn schema_pull_preserves_length_limited_char_columns_as_opaque() {
let (_container, host, port) = start_postgres().await;
let url = format!("postgres://postgres:{SECRET_PW}@{host}:{port}/postgres");
let envs = [("AUTUMN_DATABASE__URL", url.as_str())];
let (_tmp, project) = fresh_project("pull_varchar_opaque_app");
let snapshot_path = project.join(".autumn/schema-snapshot.json");
write_models(&project, "");
write_raw_migration(
&project,
"20260101000000_create_labels",
"CREATE TABLE labels (id BIGSERIAL PRIMARY KEY, code VARCHAR(32) NOT NULL, \
kind CHAR(2) NOT NULL, note TEXT, created_at TIMESTAMP NOT NULL DEFAULT NOW());",
"DROP TABLE labels;",
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
let (out, err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&out, &err);
let snap = std::fs::read_to_string(&snapshot_path).expect("pulled snapshot");
assert!(
snap.contains("\"name\": \"code\"") && snap.contains("\"pg_type\": \"varchar(32)\""),
"VARCHAR(32) is preserved as Opaque varchar(32) (NOT Text): {snap}"
);
assert!(
snap.contains("\"name\": \"kind\"") && snap.contains("\"pg_type\": \"char(2)\""),
"CHAR(2) is preserved as Opaque char(2) (NOT Text): {snap}"
);
let note_idx = snap
.find("\"name\": \"note\"")
.expect("note column present");
let note_slice = &snap[note_idx..];
assert!(
note_slice.contains("\"Text\""),
"the unbounded TEXT column stays Text: {snap}"
);
let snap_before = snap;
let (pull2_out, pull2_err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&pull2_out, &pull2_err);
let snap_after = std::fs::read_to_string(&snapshot_path).expect("snapshot after second pull");
assert_eq!(
snap_before, snap_after,
"a re-pull of the same database must be byte-for-byte stable (no varchar-length drift)"
);
let (doc_out, doc_err) = run_autumn_ok(&project, &["schema", "doctor"], &envs);
assert!(
doc_out.contains("database schema matches the snapshot baseline"),
"doctor reports the DB matches the snapshot with the length-limited columns:\n{doc_out}"
);
assert_no_secret_leak(&doc_out, &doc_err);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers); run with -- --ignored"]
async fn schema_pull_qualifies_cross_schema_foreign_key_target() {
let (_container, host, port) = start_postgres().await;
let url = format!("postgres://postgres:{SECRET_PW}@{host}:{port}/postgres");
let envs = [("AUTUMN_DATABASE__URL", url.as_str())];
let (_tmp, project) = fresh_project("pull_cross_schema_fk_app");
let snapshot_path = project.join(".autumn/schema-snapshot.json");
write_models(&project, "");
write_raw_migration(
&project,
"20260101000000_create_sessions",
"CREATE SCHEMA auth;\n\
CREATE TABLE auth.accounts (id BIGSERIAL PRIMARY KEY);\n\
CREATE TABLE public.sessions (id BIGSERIAL PRIMARY KEY, account_id BIGINT REFERENCES auth.accounts(id), created_at TIMESTAMP NOT NULL DEFAULT NOW());",
"DROP TABLE public.sessions; DROP TABLE auth.accounts; DROP SCHEMA auth;",
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
let (out, err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&out, &err);
let snap = std::fs::read_to_string(&snapshot_path).expect("pulled snapshot");
assert!(
snap.contains("\"table\": \"auth.accounts\""),
"the cross-schema FK target is recorded schema-qualified: {snap}"
);
assert!(
!snap.contains("\"table\": \"accounts\""),
"the FK target must NOT be recorded as a bare (wrong) `accounts`: {snap}"
);
let snap_before = snap;
let (pull2_out, pull2_err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&pull2_out, &pull2_err);
let snap_after = std::fs::read_to_string(&snapshot_path).expect("snapshot after second pull");
assert_eq!(
snap_before, snap_after,
"a re-pull of the same database must be byte-for-byte stable (no cross-schema FK drift)"
);
let (doc_out, doc_err) = run_autumn_ok(&project, &["schema", "doctor"], &envs);
assert!(
doc_out.contains("database schema matches the snapshot baseline"),
"doctor reports the DB matches the snapshot with the cross-schema FK:\n{doc_out}"
);
assert_no_secret_leak(&doc_out, &doc_err);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers); run with -- --ignored"]
async fn schema_pull_preserves_check_constraints() {
let (_container, host, port) = start_postgres().await;
let url = format!("postgres://postgres:{SECRET_PW}@{host}:{port}/postgres");
let envs = [("AUTUMN_DATABASE__URL", url.as_str())];
let (_tmp, project) = fresh_project("pull_check_constraint_app");
let snapshot_path = project.join(".autumn/schema-snapshot.json");
write_models(&project, "");
write_raw_migration(
&project,
"20260101000000_create_products",
"CREATE TABLE products (id BIGSERIAL PRIMARY KEY, price INTEGER NOT NULL CHECK (price >= 0), created_at TIMESTAMP NOT NULL DEFAULT NOW());",
"DROP TABLE products;",
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
let (out, err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&out, &err);
let snap = std::fs::read_to_string(&snapshot_path).expect("pulled snapshot");
assert!(
snap.contains("\"name\": \"products\""),
"products pulled: {snap}"
);
assert!(
snap.contains("\"checks\"") && snap.contains("price >= 0"),
"the CHECK constraint predicate is preserved on pull (not dropped): {snap}"
);
let snap_before = snap;
let (pull2_out, pull2_err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&pull2_out, &pull2_err);
let snap_after = std::fs::read_to_string(&snapshot_path).expect("snapshot after second pull");
assert_eq!(
snap_before, snap_after,
"a re-pull of the same database must be byte-for-byte stable (no check drift)"
);
let (doc_out, doc_err) = run_autumn_ok(&project, &["schema", "doctor"], &envs);
assert!(
doc_out.contains("database schema matches the snapshot baseline"),
"doctor reports the DB matches the snapshot with the CHECK constraint:\n{doc_out}"
);
assert_no_secret_leak(&doc_out, &doc_err);
}
fn snapshot_columns(snap: &str) -> std::collections::BTreeMap<String, serde_json::Value> {
let doc: serde_json::Value = serde_json::from_str(snap).expect("snapshot is valid JSON");
let mut out = std::collections::BTreeMap::new();
for table in doc["tables"].as_array().expect("tables array") {
let name = table["name"].as_str().expect("table name").to_owned();
out.insert(name, table.clone());
}
out
}
fn column_json<'a>(table: &'a serde_json::Value, column: &str) -> &'a serde_json::Value {
table["columns"]
.as_array()
.expect("columns array")
.iter()
.find(|c| c["name"].as_str() == Some(column))
.unwrap_or_else(|| panic!("column `{column}` not found in table"))
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers); run with -- --ignored"]
async fn schema_pull_distinguishes_plain_bigint_pk_from_bigserial() {
let (_container, host, port) = start_postgres().await;
let url = format!("postgres://postgres:{SECRET_PW}@{host}:{port}/postgres");
let envs = [("AUTUMN_DATABASE__URL", url.as_str())];
let (_tmp, project) = fresh_project("pull_serial_app");
let snapshot_path = project.join(".autumn/schema-snapshot.json");
write_models(&project, "");
write_raw_migration(
&project,
"20260101000000_serial_shapes",
"CREATE TABLE serial_ids (id BIGSERIAL PRIMARY KEY, label TEXT NOT NULL, \
created_at TIMESTAMP NOT NULL DEFAULT now());\n\
CREATE TABLE plain_ids (id BIGINT PRIMARY KEY, label TEXT NOT NULL, \
created_at TIMESTAMP NOT NULL DEFAULT now());\n",
"DROP TABLE plain_ids;\nDROP TABLE serial_ids;\n",
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
let (pull_out, pull_err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&pull_out, &pull_err);
let snap = std::fs::read_to_string(&snapshot_path).expect("pulled snapshot");
let tables = snapshot_columns(&snap);
let serial_id = column_json(&tables["serial_ids"], "id");
assert_eq!(
serial_id["serial"].as_str(),
Some("BigSerial"),
"a BIGSERIAL id must carry the serial marker: {serial_id}"
);
let plain_id = column_json(&tables["plain_ids"], "id");
assert_eq!(
plain_id["serial"].as_str(),
Some("Plain"),
"a plain BIGINT PRIMARY KEY must carry the EXPLICIT Plain marker (three-state: \
never absent, which is reserved for legacy/unknown snapshots): {plain_id}"
);
run_autumn_ok(&project, &["schema", "pull"], &envs);
let snap_after = std::fs::read_to_string(&snapshot_path).expect("second pull");
assert_eq!(snap, snap_after, "a re-pull must be byte-for-byte stable");
write_models(
&project,
"#[autumn_web::model(managed)]\npub struct SerialId {\n #[id]\n pub id: i64,\n \
pub label: String,\n #[default]\n pub created_at: chrono::NaiveDateTime,\n}\n\n\
#[autumn_web::model(managed)]\npub struct PlainId {\n #[id]\n pub id: i64,\n \
pub label: String,\n #[default]\n pub created_at: chrono::NaiveDateTime,\n}\n",
);
let (diff_out, diff_err, code) = run_autumn(&project, &["schema", "diff"], &envs);
let combined = format!("{diff_out}{diff_err}");
assert_ne!(
code,
Some(0),
"a plain-BIGINT-PK vs BigSerial model must drift:\n{combined}"
);
assert!(
combined.contains("primary-key change") && combined.contains("plain_ids"),
"the drift is the plain_ids primary-key change (serial_ids matches):\n{combined}"
);
assert!(
!combined.contains("serial_ids"),
"the BIGSERIAL table round-trips clean — only plain_ids drifts:\n{combined}"
);
assert_no_secret_leak(&diff_out, &diff_err);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers); run with -- --ignored"]
async fn schema_pull_preserves_generated_as_identity() {
let (_container, host, port) = start_postgres().await;
let url = format!("postgres://postgres:{SECRET_PW}@{host}:{port}/postgres");
let envs = [("AUTUMN_DATABASE__URL", url.as_str())];
let (_tmp, project) = fresh_project("pull_identity_app");
let snapshot_path = project.join(".autumn/schema-snapshot.json");
write_models(&project, "");
write_raw_migration(
&project,
"20260101000000_identity",
"CREATE TABLE identity_rows (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, \
label TEXT NOT NULL);\n",
"DROP TABLE identity_rows;\n",
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
let (pull_out, pull_err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&pull_out, &pull_err);
let snap = std::fs::read_to_string(&snapshot_path).expect("pulled snapshot");
let tables = snapshot_columns(&snap);
let id = column_json(&tables["identity_rows"], "id");
assert_eq!(
id["identity"].as_str(),
Some("ALWAYS"),
"the identity generation clause is preserved verbatim: {id}"
);
assert!(
id.get("serial").is_none(),
"an identity column is not a serial: {id}"
);
run_autumn_ok(&project, &["schema", "pull"], &envs);
let snap_after = std::fs::read_to_string(&snapshot_path).expect("second pull");
assert_eq!(snap, snap_after, "a re-pull must be byte-for-byte stable");
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers); run with -- --ignored"]
async fn schema_pull_retains_nulls_not_distinct_unique_index() {
let (_container, host, port) = start_postgres().await;
let url = format!("postgres://postgres:{SECRET_PW}@{host}:{port}/postgres");
let envs = [("AUTUMN_DATABASE__URL", url.as_str())];
let (_tmp, project) = fresh_project("pull_nnd_app");
let snapshot_path = project.join(".autumn/schema-snapshot.json");
write_models(&project, "");
write_raw_migration(
&project,
"20260101000000_nnd",
"CREATE TABLE nnd (id BIGSERIAL PRIMARY KEY, code TEXT);\n\
CREATE UNIQUE INDEX nnd_code_uniq ON nnd (code) NULLS NOT DISTINCT;\n",
"DROP TABLE nnd;\n",
);
run_autumn_ok(&project, &["schema", "migrate"], &envs);
let (pull_out, pull_err) = run_autumn_ok(&project, &["schema", "pull"], &envs);
assert_no_secret_leak(&pull_out, &pull_err);
let snap = std::fs::read_to_string(&snapshot_path).expect("pulled snapshot");
assert!(
snap.contains("NULLS NOT DISTINCT"),
"the NULLS NOT DISTINCT clause is retained verbatim in the index definition: {snap}"
);
run_autumn_ok(&project, &["schema", "pull"], &envs);
let snap_after = std::fs::read_to_string(&snapshot_path).expect("second pull");
assert_eq!(snap, snap_after, "a re-pull must be byte-for-byte stable");
}
#[cfg(not(feature = "sqlite"))]
#[test]
fn schema_pull_refuses_sqlite_backend() {
let (_tmp, project) = fresh_project("pull_sqlite_refusal_app");
let snapshot_path = project.join(".autumn/schema-snapshot.json");
let existed_before = snapshot_path.exists();
let envs = [("AUTUMN_DATABASE__URL", "sqlite://./pull_test.db")];
let (stdout, stderr) = run_autumn_fail(&project, &["schema", "pull"], &envs);
let combined = format!("{stdout}{stderr}");
assert!(
combined.contains("SQLite") && combined.to_lowercase().contains("postgres only"),
"refusal names SQLite as a future slice:\n{combined}"
);
assert_eq!(
existed_before,
snapshot_path.exists(),
"a refused SQLite pull must not create a snapshot file"
);
}