feat: migrator: +multifiles migrations

This commit is contained in:
2026-02-28 21:19:29 +00:00
parent 525c6a220b
commit 577c167d5a
16 changed files with 263 additions and 22 deletions

View File

@@ -49,7 +49,6 @@ in {
''ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBKPbIJATVyAw7F7vBZbHkCODXFo5gvDyqhuU0gnNUNH''
];
boot.loader.grub.device = "/dev/vda";
boot.initrd.availableKernelModules = [
"ata_piix"
"uhci_hcd"

View File

@@ -431,8 +431,14 @@ ${BGREEN}Examples:
${BGREEN}Migration File Structure:$NC
migration/
└── 20231201120000-migration-name/
├── up.sql - Forward migration
└── down.sql - Rollback migration
├── up.sql - Forward migration (single file)
└── down.sql - Rollback migration (single file)
${BBLACK}# or with multi-file layout:$NC
└── 20231201120000-migration-name/
├── up/
│ └── entrypoint.sql - Forward migration entrypoint
└── down/
└── entrypoint.sql - Rollback migration entrypoint
${BGREEN}Migration Naming:$NC
Migrations must follow the format: YYYYMMDDHHMMSS-description
@@ -609,6 +615,39 @@ migration_list() {
find "$MIGRATION_DIR" -maxdepth 1 -type d -regextype posix-extended -regex '^.*/[0-9]{14}-.*$' -printf '%f\n' | sort
}
# resolve_migration_path(migration_name, direction)
# direction: "up" or "down"
# Resolves the SQL file for a migration, supporting two layouts:
# 1. MIGRATION_DIR/<name>/up.sql (single-file)
# 2. MIGRATION_DIR/<name>/up/entrypoint.sql (multi-file)
# Returns the resolved path on stdout, exits with error if neither found.
resolve_migration_path() {
local name="$1" direction="$2"
local single_file="$MIGRATION_DIR/$name/${direction}.sql"
local multi_file="$MIGRATION_DIR/$name/${direction}/entrypoint.sql"
if [ -f "$single_file" ]; then
printf '%s' "$single_file"
elif [ -f "$multi_file" ]; then
printf '%s' "$multi_file"
else
log error "migration ${direction} not found for ${WHITE}$name${NC}"
log error "expected either ${WHITE}$single_file${NC} or ${WHITE}$multi_file${NC}"
exit 1
fi
}
# has_migration_direction(migration_name, direction)
# direction: "up" or "down"
# Returns 0 if the migration has the given direction, 1 otherwise.
has_migration_direction() {
local name="$1" direction="$2"
local single_file="$MIGRATION_DIR/$name/${direction}.sql"
local multi_file="$MIGRATION_DIR/$name/${direction}/entrypoint.sql"
[ -f "$single_file" ] || [ -f "$multi_file" ]
}
# index_of(array, name)
index_of() {
local list name m i=1
@@ -790,14 +829,9 @@ migrate() {
fs_migration=$(printf '%s' "$fs_migrations" | sed -n "${i}p")
escaped_name=$(printf '%s' "$fs_migration" | sed "s/'/''/g")
mig_path="$MIGRATION_DIR/$fs_migration/up.sql"
mig_path=$(resolve_migration_path "$fs_migration" "up")
escaped_path=$(printf '%s' "$mig_path" | sed "s/'/''/g")
if [ ! -f "$mig_path" ]; then
log error "migration file not found: ${WHITE}$mig_path${NC}"
exit 1
fi
mig_hash=$(sha256sum "$mig_path")
log info "applying migration ${WHITE}$fs_migration${NC} (up)"
@@ -847,14 +881,9 @@ SQL
fs_migration=$(printf '%s' "$fs_migrations" | sed -n "${i}p")
escaped_name=$(printf '%s' "$fs_migration" | sed "s/'/''/g")
mig_path="$MIGRATION_DIR/$fs_migration/down.sql"
mig_path=$(resolve_migration_path "$fs_migration" "down")
escaped_path=$(printf '%s' "$mig_path" | sed "s/'/''/g")
if [ ! -f "$mig_path" ]; then
log error "migration file not found: ${WHITE}$mig_path${NC}"
exit 1
fi
log info "reverting migration ${WHITE}$fs_migration${NC} (down)"
case "$db_type" in
@@ -981,12 +1010,27 @@ list() {
}
migration_list | while read -r name; do
dir="./${MIGRATION_DIR}/${name}"
up="$dir/up.sql"
down="$dir/down.sql"
has_up=true
has_down=true
if ! has_migration_direction "$name" "up"; then
has_up=false
fi
if ! has_migration_direction "$name" "down"; then
has_down=false
fi
if [ ! -f "$up" ] || [ ! -f "$down" ]; then
echo "$name: missing $( [ ! -f "$up" ] && echo up.sql ) $( [ ! -f "$down" ] && echo down.sql )"
if [ "$has_up" = false ] || [ "$has_down" = false ]; then
missing=""
[ "$has_up" = false ] && missing="up.sql"
if [ "$has_down" = false ]; then
if [ -n "$missing" ]; then
missing="$missing down.sql"
else
missing="down.sql"
fi
fi
echo "$name: missing $missing"
else
echo "$name"
fi

View File

@@ -0,0 +1,4 @@
CREATE TABLE items (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL
);

View File

@@ -0,0 +1,2 @@
ALTER TABLE items DROP COLUMN price;
ALTER TABLE items DROP COLUMN description;

View File

@@ -0,0 +1,2 @@
ALTER TABLE items ADD COLUMN description TEXT;
ALTER TABLE items ADD COLUMN price NUMERIC;

View File

@@ -0,0 +1,84 @@
#!/bin/dash
HECTIC_NAMESPACE=test-migrate-multi-file
log notice "test case: ${WHITE}migrate up with multi-file layout (up/entrypoint.sql)"
# Initialize migrator
if ! migrator --db-url "$DATABASE_URL" init; then
log error "test failed: ${WHITE}init failed"
exit 1
fi
# Apply first migration (up/entrypoint.sql)
if ! migrator --db-url "$DATABASE_URL" migrate up; then
log error "test failed: ${WHITE}first migrate up failed"
exit 1
fi
# Verify migration was applied
applied_count=$(psql -Atc "SELECT COUNT(*) FROM hectic.migration" "$DATABASE_URL")
if [ "$applied_count" != "1" ]; then
log error "test failed: ${WHITE}expected 1 migration, got $applied_count"
exit 1
fi
# Verify table was created
if ! psql -Atc "SELECT COUNT(*) FROM items" "$DATABASE_URL" >/dev/null 2>&1; then
log error "test failed: ${WHITE}items table not created"
exit 1
fi
log info "first migration applied successfully"
# Apply second migration
if ! migrator --db-url "$DATABASE_URL" migrate up; then
log error "test failed: ${WHITE}second migrate up failed"
exit 1
fi
# Verify both columns were added
if ! psql -Atc "SELECT description FROM items LIMIT 0" "$DATABASE_URL" >/dev/null 2>&1; then
log error "test failed: ${WHITE}description column not added"
exit 1
fi
if ! psql -Atc "SELECT price FROM items LIMIT 0" "$DATABASE_URL" >/dev/null 2>&1; then
log error "test failed: ${WHITE}price column not added"
exit 1
fi
log info "second migration applied successfully"
# Migrate down one step
if ! migrator --db-url "$DATABASE_URL" migrate down; then
log error "test failed: ${WHITE}migrate down failed"
exit 1
fi
# Verify only 1 migration remains
applied_count=$(psql -Atc "SELECT COUNT(*) FROM hectic.migration" "$DATABASE_URL")
if [ "$applied_count" != "1" ]; then
log error "test failed: ${WHITE}expected 1 migration after down, got $applied_count"
exit 1
fi
# Verify columns were removed
if psql -Atc "SELECT description FROM items LIMIT 0" "$DATABASE_URL" >/dev/null 2>&1; then
log error "test failed: ${WHITE}description column should be removed"
exit 1
fi
# Migrate down to clean state
if ! migrator --db-url "$DATABASE_URL" migrate down; then
log error "test failed: ${WHITE}second migrate down failed"
exit 1
fi
# Verify items table was dropped
if psql -Atc "SELECT COUNT(*) FROM items" "$DATABASE_URL" >/dev/null 2>&1; then
log error "test failed: ${WHITE}items table should be dropped"
exit 1
fi
log notice "test passed: multi-file migration layout works correctly"

View File

@@ -13,7 +13,9 @@ printf '%s' "$result" > result
printf '20251004192425-some-changes
20251004292448-some-changes
20251104172425-third-migration
20251104192426-multi-file-both
20251104192427-an-other-one
20251104192428-multi-file-up-only
20251104292469-almoust-last
20251204152446-very-last' > expected
@@ -35,8 +37,10 @@ printf '%s' "$result" > result
printf '20251004192425-some-changes: missing up.sql down.sql
20251004292448-some-changes
20251104172425-third-migration: missing down.sql
20251104192427-an-other-one: missing down.sql
20251104172425-third-migration: missing down.sql
20251104192426-multi-file-both
20251104192427-an-other-one: missing down.sql
20251104192428-multi-file-up-only: missing down.sql
20251104292469-almoust-last
20251204152446-very-last' > expected

View File

@@ -0,0 +1,4 @@
CREATE TABLE items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
);

View File

@@ -0,0 +1,4 @@
CREATE TABLE items_backup (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);
INSERT INTO items_backup SELECT id, name FROM items;
DROP TABLE items;
ALTER TABLE items_backup RENAME TO items;

View File

@@ -0,0 +1,2 @@
ALTER TABLE items ADD COLUMN description TEXT;
ALTER TABLE items ADD COLUMN price REAL;

View File

@@ -0,0 +1,90 @@
#!/bin/dash
HECTIC_NAMESPACE=test-sqlite-multi-file
log notice "test case: ${WHITE}SQLite multi-file migration layout"
# Create SQLite database
SQLITE_DB="$PWD/test.db"
export DB_URL="sqlite://$SQLITE_DB"
log info "using SQLite database: $SQLITE_DB"
# Initialize migrator
if ! migrator --db-url "$DB_URL" init; then
log error "test failed: ${WHITE}init failed for SQLite"
exit 1
fi
# Apply first migration (up/entrypoint.sql)
if ! migrator --db-url "$DB_URL" migrate up; then
log error "test failed: ${WHITE}first migration failed"
exit 1
fi
# Verify migration was applied
migration_count=$(sqlite3 "$SQLITE_DB" "SELECT COUNT(*) FROM hectic_migration")
if [ "$migration_count" != "1" ]; then
log error "test failed: ${WHITE}expected 1 migration, got $migration_count"
exit 1
fi
# Verify table was created
if ! sqlite3 "$SQLITE_DB" "SELECT COUNT(*) FROM items" >/dev/null 2>&1; then
log error "test failed: ${WHITE}items table not created"
exit 1
fi
log info "first migration applied successfully"
# Apply second migration
if ! migrator --db-url "$DB_URL" migrate up; then
log error "test failed: ${WHITE}second migration failed"
exit 1
fi
# Verify columns were added
if ! sqlite3 "$SQLITE_DB" "SELECT description FROM items LIMIT 0" >/dev/null 2>&1; then
log error "test failed: ${WHITE}description column not added"
exit 1
fi
if ! sqlite3 "$SQLITE_DB" "SELECT price FROM items LIMIT 0" >/dev/null 2>&1; then
log error "test failed: ${WHITE}price column not added"
exit 1
fi
log info "second migration applied successfully"
# Migrate down one step
if ! migrator --db-url "$DB_URL" migrate down; then
log error "test failed: ${WHITE}migrate down failed"
exit 1
fi
# Verify only 1 migration remains
migration_count=$(sqlite3 "$SQLITE_DB" "SELECT COUNT(*) FROM hectic_migration")
if [ "$migration_count" != "1" ]; then
log error "test failed: ${WHITE}expected 1 migration after down, got $migration_count"
exit 1
fi
# Verify columns were removed (table recreated without extra columns)
if sqlite3 "$SQLITE_DB" "SELECT description FROM items LIMIT 0" >/dev/null 2>&1; then
log error "test failed: ${WHITE}description column should be removed"
exit 1
fi
# Migrate down to clean state
if ! migrator --db-url "$DB_URL" migrate down; then
log error "test failed: ${WHITE}second migrate down failed"
exit 1
fi
# Verify items table was dropped
if sqlite3 "$SQLITE_DB" "SELECT COUNT(*) FROM items" >/dev/null 2>&1; then
log error "test failed: ${WHITE}items table should be dropped"
exit 1
fi
log notice "test passed: SQLite multi-file migration layout works correctly"