diff --git a/package/migrator/migrator.sh b/package/migrator/migrator.sh index 514aa41..a0bbb82 100644 --- a/package/migrator/migrator.sh +++ b/package/migrator/migrator.sh @@ -32,6 +32,11 @@ sha256sum() { # detect_db_type() # Returns: "postgresql" or "sqlite" detect_db_type() { + if ! [ "${DB_URL+x}" ]; then + log error "no ${WHITE}DB_URL${NC} or ${WHITE}--db-url${NC} specified" + exit 3 + fi + case "$DB_URL" in postgresql://*|postgres://*) printf 'postgresql' @@ -42,7 +47,7 @@ detect_db_type() { *) log error "unsupported database URL format: ${WHITE}$DB_URL${NC}" log error "supported formats: postgresql://... or sqlite://... or *.db" - exit 1 + exit 3 ;; esac } @@ -666,22 +671,67 @@ migrate() { db_mig_count=$(printf '%s' "$db_migrations" | wc -l) log debug "mig count: $db_mig_count" + # Log migration lists for debugging + fs_mig_count=$(printf '%s' "$fs_migrations" | wc -l) + log info "Filesystem migrations found: ${WHITE}$fs_mig_count" + log info "Database migrations applied: ${WHITE}$db_mig_count" + # Check if the DB migrations form a proper prefix of disk migrations # (meaning all DB-applied migration filenames should appear in the same order at the start). i=0 for db_migration in $db_migrations; do fs_migration=$(printf '%s' "$fs_migrations" | sed -n "$((i+1))p") + log debug "Checking migration $((i+1)): DB=${WHITE}$db_migration${NC} vs FS=${WHITE}$fs_migration" + if [ -z "$fs_migration" ] || [ "$fs_migration" != "$db_migration" ]; then - if [ -z "$FORCE" ]; then - log error "unrelated migration tree detected. Use --force to proceed." + if ! [ "${FORCE+x}" ]; then + log error "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + log error "${RED}Migration history mismatch detected!${NC}" + log error "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + log error "" + log error "Position: Migration #$((i+1))" + log error "Database has: ${WHITE}$db_migration" + log error "Filesystem has: ${WHITE}$fs_migration" + log error "" + log error "Full filesystem migrations (in order):" + j=1 + printf '%s\n' "$fs_migrations" | while IFS= read -r m; do + log error " $j. ${CYAN}$m" + j=$((j+1)) + done + log error "" + log error "Full database migrations (in order):" + j=1 + printf '%s\n' "$db_migrations" | while IFS= read -r m; do + log error " $j. ${CYAN}$m" + j=$((j+1)) + done + log error "" + log error "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + log error "This usually means:" + log error " • Migration files were removed or renamed" + log error " • Migrations were applied out of order" + log error " • Database and codebase are from different versions" + log error "" + log error "${YELLOW}To proceed anyway, use: ${WHITE}--force${NC}${YELLOW}!${NC}" + log error "${YELLOW}Warning: This may cause data inconsistencies!${NC}" + log error "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" exit 2 else - log error "unrelated migration tree forced. Proceeding..." + log notice "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + log notice "${YELLOW}Migration history mismatch detected but ${WHITE}--force${NC}${YELLOW} specified${NC}" + log notice "Position: Migration #$((i+1))" + log notice "Database has: ${WHITE}$db_migration" + log notice "Filesystem has: ${WHITE}$fs_migration" + log notice "${YELLOW}Proceeding with migration despite mismatch...${NC}" + log notice "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" break fi fi i=$((i+1)) done + + log info "Migration history validation: ${GREEN}OK${NC} (${WHITE}$i${NC} migrations match)" eval "set -- $MIGRATOR_REMAINING_ARS" target_migration="$("migrate_$MIGRATE_SUBCOMMAND" "$@")" diff --git a/test/package/migrator/test/postgresql/migration-history-mismatch.sh b/test/package/migrator/test/postgresql/migration-history-mismatch.sh new file mode 100644 index 0000000..f8ccf15 --- /dev/null +++ b/test/package/migrator/test/postgresql/migration-history-mismatch.sh @@ -0,0 +1,104 @@ +#!/bin/dash + +HECTIC_NAMESPACE=test-migration-history-mismatch + +log notice "test case: ${WHITE}migration history mismatch detection" + +# Initialize database +psql "$DATABASE_URL" -c 'CREATE TABLE test_table (id INTEGER PRIMARY KEY)' + +if ! migrator --db-url "$DATABASE_URL" init; then + log error "test failed: ${WHITE}init failed" + exit 1 +fi + +# Create migration directory with 3 migrations +mkdir -p migration +for i in 1 2 3; do + mig_name="2025010100000${i}-migration-${i}" + mkdir -p "migration/${mig_name}" + echo "ALTER TABLE test_table ADD COLUMN col${i} TEXT;" > "migration/${mig_name}/up.sql" + echo "ALTER TABLE test_table DROP COLUMN col${i};" > "migration/${mig_name}/down.sql" +done + +# Apply all migrations +migrator --db-url "$DATABASE_URL" migrate up all + +applied_count=$(psql -Atc "SELECT COUNT(*) FROM hectic.migration" "$DATABASE_URL") +if [ "$applied_count" != "3" ]; then + log error "test failed: ${WHITE}setup failed, expected 3 migrations" + exit 1 +fi + +log info "setup complete: 3 migrations applied" + +### CASE 1: Remove a migration file (causes mismatch) +log notice "test case: ${WHITE}detect removed migration file" + +# Remove the second migration directory +rm -rf "migration/20250101000002-migration-2" + +# Try to migrate (should fail with detailed error) +set +e +output=$(migrator --db-url "$DATABASE_URL" migrate up 2>&1) +exit_code=$? +set -e + +if [ "$exit_code" != "2" ]; then + log error "test failed: ${WHITE}expected exit code 2, got $exit_code" + exit 1 +fi + +# Check that error message contains helpful information +if ! printf '%s' "$output" | grep -q "Migration history mismatch"; then + log error "test failed: ${WHITE}error message doesn't contain 'Migration history mismatch'" + exit 1 +fi + +if ! printf '%s' "$output" | grep -q "Database has:"; then + log error "test failed: ${WHITE}error message doesn't show database migration" + exit 1 +fi + +if ! printf '%s' "$output" | grep -q "Filesystem has:"; then + log error "test failed: ${WHITE}error message doesn't show filesystem migration" + exit 1 +fi + +if ! printf '%s' "$output" | grep -q "Full filesystem migrations"; then + log error "test failed: ${WHITE}error message doesn't list all filesystem migrations" + exit 1 +fi + +if ! printf '%s' "$output" | grep -q "Full database migrations"; then + log error "test failed: ${WHITE}error message doesn't list all database migrations" + exit 1 +fi + +log info "detailed error message verified" + +### CASE 2: Verify --force flag works +log notice "test case: ${WHITE}--force flag bypasses check" + +# Try again with --force (should work) +set +e +output=$(migrator --db-url "$DATABASE_URL" --force migrate up 2>&1) +exit_code=$? +set -e + +# Note: It will still fail because migration file is missing, but it should bypass the tree check +if [ "$exit_code" = "2" ]; then + log error "test failed: ${WHITE}--force didn't bypass tree check (exit code still 2)" + exit 1 +fi + +# Check that we got past the tree check +if printf '%s' "$output" | grep -q "Migration history mismatch detected but.*--force.*specified"; then + log info "--force flag bypassed tree check as expected" +else + # It might have proceeded to file not found error, which is fine + log info "--force flag behavior verified (proceeded past tree check)" +fi + +log notice "test passed" + diff --git a/test/package/migrator/test/sqlite/migrate-up-all.sh b/test/package/migrator/test/sqlite/migrate-up-all.sh new file mode 100644 index 0000000..8885e1d --- /dev/null +++ b/test/package/migrator/test/sqlite/migrate-up-all.sh @@ -0,0 +1,182 @@ +#!/bin/dash + +HECTIC_NAMESPACE=test-sqlite-migrate-up-all + +log notice "test case: ${WHITE}SQLite migrate up all - comprehensive test" + +# Create SQLite database +SQLITE_DB="$PWD/test.db" +export DB_URL="sqlite://$SQLITE_DB" + +log info "using SQLite database: $SQLITE_DB" + +# Create initial schema +sqlite3 "$SQLITE_DB" "CREATE TABLE inventory (id INTEGER PRIMARY KEY, name TEXT)" + +# Initialize migrator +if ! migrator --db-url "$DB_URL" init; then + log error "test failed: ${WHITE}init failed" + exit 1 +fi + +# Verify tables were created +if ! sqlite3 "$SQLITE_DB" "SELECT COUNT(*) FROM hectic_migration" >/dev/null 2>&1; then + log error "test failed: ${WHITE}hectic_migration table not created" + exit 1 +fi + +log info "migrator initialized successfully" + +# Create migrations directory with 5 migrations +mkdir -p migration +log info "creating 5 test migrations" + +for i in 1 2 3 4 5; do + mig_name="2025010100000${i}-add-field-${i}" + mkdir -p "migration/${mig_name}" + + echo "ALTER TABLE inventory ADD COLUMN field${i} TEXT;" > "migration/${mig_name}/up.sql" + + # Simple down migration (comment only for this test) + cat > "migration/${mig_name}/down.sql" </dev/null 2>&1; then + log error "test failed: ${WHITE}field${i} column not added" + exit 1 + fi +done + +log info "all columns verified" + +### CASE 2: Already at latest - should be no-op +log notice "test case: ${WHITE}migrate up all when already at latest (no-op)" + +if ! migrator --db-url "$DB_URL" migrate up all; then + log error "test failed: ${WHITE}migrate up all (no-op) failed" + exit 1 +fi + +# Count should still be 5 +applied_count=$(sqlite3 "$SQLITE_DB" "SELECT COUNT(*) FROM hectic_migration") +if [ "$applied_count" != "5" ]; then + log error "test failed: ${WHITE}expected 5 migrations after no-op, got $applied_count" + exit 1 +fi + +log info "no-op successful - count still 5" + +### CASE 3: Insert data, then verify migrations preserve it +log notice "test case: ${WHITE}migrations don't corrupt existing data" + +sqlite3 "$SQLITE_DB" < "migration/${mig_name}/up.sql" +echo "-- Revert field6" > "migration/${mig_name}/down.sql" + +# Apply new migration +if ! migrator --db-url "$DB_URL" migrate up all; then + log error "test failed: ${WHITE}migrate up all with new migration failed" + exit 1 +fi + +# Verify data still exists +data_count_after=$(sqlite3 "$SQLITE_DB" "SELECT COUNT(*) FROM inventory") +if [ "$data_count_after" != "$data_count" ]; then + log error "test failed: ${WHITE}data corrupted after migration, had $data_count, now $data_count_after" + exit 1 +fi + +# Verify new column has default value +field6_value=$(sqlite3 "$SQLITE_DB" "SELECT field6 FROM inventory WHERE name = 'Item1'") +if [ "$field6_value" != "default6" ]; then + log error "test failed: ${WHITE}new column default value not applied, got: $field6_value" + exit 1 +fi + +log info "data preserved after migration, new column added with default" + +### CASE 4: Verify migration tracking metadata +log notice "test case: ${WHITE}migration metadata is correct" + +applied_count=$(sqlite3 "$SQLITE_DB" "SELECT COUNT(*) FROM hectic_migration") +if [ "$applied_count" != "6" ]; then + log error "test failed: ${WHITE}expected 6 migrations in total, got $applied_count" + exit 1 +fi + +# Verify migrations are in order +first_mig=$(sqlite3 "$SQLITE_DB" "SELECT name FROM hectic_migration ORDER BY id ASC LIMIT 1") +if [ "$first_mig" != "20250101000001-add-field-1" ]; then + log error "test failed: ${WHITE}first migration not correct, got: $first_mig" + exit 1 +fi + +last_mig=$(sqlite3 "$SQLITE_DB" "SELECT name FROM hectic_migration ORDER BY id DESC LIMIT 1") +if [ "$last_mig" != "20250101000006-add-field-6" ]; then + log error "test failed: ${WHITE}last migration not correct, got: $last_mig" + exit 1 +fi + +# Verify hashes are tracked +hash_count=$(sqlite3 "$SQLITE_DB" "SELECT COUNT(*) FROM hectic_migration WHERE hash IS NOT NULL AND hash != ''") +if [ "$hash_count" != "6" ]; then + log error "test failed: ${WHITE}not all migrations have hashes, got $hash_count/6" + exit 1 +fi + +log info "migration metadata verified" + +### CASE 5: Verify version table +log notice "test case: ${WHITE}version table is correct" + +version=$(sqlite3 "$SQLITE_DB" "SELECT version FROM hectic_version WHERE name = 'migrator'") +if [ "$version" != "0.0.1" ]; then + log error "test failed: ${WHITE}version not correct, got: $version" + exit 1 +fi + +log info "version table verified" + +log notice "test passed: all SQLite 'migrate up all' scenarios work correctly" +