From 166fa0b46f7efcd7de11aa881c26878132ad06ff Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Tue, 28 Apr 2026 22:05:36 +1000 Subject: [PATCH] feat: add rollback CLI --- README.md | 30 ++++++++++++++++++ src/sam.cr | 53 +++++++++++++++++++++++++++++++ src/tasks/database.cr | 74 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+) diff --git a/README.md b/README.md index e538d4c..cff5525 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,22 @@ Execute scripts as one-off container jobs. docker-compose run --no-deps -it init task db:init host=$PG_HOST port=$PG_PORT db=$PG_DB user=$PG_USER password=$PG_PASSWORD ``` +```bash +# Roll back the last migration +docker-compose run --no-deps -it init task db:down host=$PG_HOST port=$PG_PORT db=$PG_DB user=$PG_USER password=$PG_PASSWORD +``` + +```bash +# Roll back to a specific migration version (rolls down until current <= target) +docker-compose run --no-deps -it init task db:down target=20240101000000 host=$PG_HOST port=$PG_PORT db=$PG_DB user=$PG_USER password=$PG_PASSWORD +``` + +```bash +# Inspect migration status / current version +docker-compose run --no-deps -it init task db:status host=$PG_HOST port=$PG_PORT db=$PG_DB user=$PG_USER password=$PG_PASSWORD +docker-compose run --no-deps -it init task db:version host=$PG_HOST port=$PG_PORT db=$PG_DB user=$PG_USER password=$PG_PASSWORD +``` + ```bash # Dump PostgreSQL database to local filesystem docker-compose run --no-deps -it init task db:dump host=$PG_HOST port=$PG_PORT db=$PG_DB user=$PG_USER password=$PG_PASSWORD @@ -238,6 +254,20 @@ By default, the backup will take place at midnight every day. * `user`: Defaults to `PG_USER` || `"postgres"` * `password`: Defaults to `PG_PASS` || `""` +- `db:down`: Roll back the last migration, or down to a specific version. + * `db`: Defaults `PG_DB` || `"postgres"` + * `host`: Defaults to `PG_HOST` || `"localhost"` + * `port`: Defaults to `PG_PORT` || `5432` + * `user`: Defaults to `PG_USER` || `"postgres"` + * `password`: Defaults to `PG_PASS` || `""` + * `target`: Optional version (e.g. `20240101000000`). When supplied, rolls down repeatedly until the current version is `<= target`. Use `target=0` to revert all migrations. + +- `db:redo`: Re-run the last migration (rolls back, then re-applies). + +- `db:status`: Show migration status for each migration. + +- `db:version`: Print the current database migration version. + - `db:clean`: Clean PostgreSQL Database by deleting old records. * `db`: Defaults `PG_DB` || `"postgres"` * `host`: Defaults to `PG_HOST` || `"localhost"` diff --git a/src/sam.cr b/src/sam.cr index 67f89fb..69a62de 100644 --- a/src/sam.cr +++ b/src/sam.cr @@ -49,6 +49,59 @@ namespace "db" do puts "PostgreSQL database '#{args["db"]}' restored successfully from dump file '#{args["path"]}'" if ret end + desc "Roll back the last migration, or down to `target=`" + task "down" do |_, args| + arguments = { + pg_host: (args["host"]? || PlaceOS::PG_HOST).to_s, + pg_port: (args["port"]? || PlaceOS::PG_PORT).to_i, + pg_db: (args["db"]? || PlaceOS::PG_DB).to_s, + pg_user: (args["user"]? || PlaceOS::PG_USER).try &.to_s, + pg_password: (args["password"]? || PlaceOS::PG_PASS).to_s, + target: args["target"]?.try(&.to_s.to_i64), + } + + PlaceOS::Tasks.pg_migrate_down(**arguments) + end + + desc "Re-run the last migration" + task "redo" do |_, args| + arguments = { + pg_host: (args["host"]? || PlaceOS::PG_HOST).to_s, + pg_port: (args["port"]? || PlaceOS::PG_PORT).to_i, + pg_db: (args["db"]? || PlaceOS::PG_DB).to_s, + pg_user: (args["user"]? || PlaceOS::PG_USER).try &.to_s, + pg_password: (args["password"]? || PlaceOS::PG_PASS).to_s, + } + + PlaceOS::Tasks.pg_migrate_redo(**arguments) + end + + desc "Show migration status for the PostgreSQL DB" + task "status" do |_, args| + arguments = { + pg_host: (args["host"]? || PlaceOS::PG_HOST).to_s, + pg_port: (args["port"]? || PlaceOS::PG_PORT).to_i, + pg_db: (args["db"]? || PlaceOS::PG_DB).to_s, + pg_user: (args["user"]? || PlaceOS::PG_USER).try &.to_s, + pg_password: (args["password"]? || PlaceOS::PG_PASS).to_s, + } + + PlaceOS::Tasks.pg_migration_status(**arguments) + end + + desc "Print the current PostgreSQL DB migration version" + task "version" do |_, args| + arguments = { + pg_host: (args["host"]? || PlaceOS::PG_HOST).to_s, + pg_port: (args["port"]? || PlaceOS::PG_PORT).to_i, + pg_db: (args["db"]? || PlaceOS::PG_DB).to_s, + pg_user: (args["user"]? || PlaceOS::PG_USER).try &.to_s, + pg_password: (args["password"]? || PlaceOS::PG_PASS).to_s, + } + + PlaceOS::Tasks.pg_database_version(**arguments) + end + desc "Clean PostgreSQL Database by deleting old records" task "clean" do |_, args| arguments = { diff --git a/src/tasks/database.cr b/src/tasks/database.cr index 9b3bf9d..1bebc3e 100644 --- a/src/tasks/database.cr +++ b/src/tasks/database.cr @@ -26,6 +26,80 @@ module PlaceOS::Tasks::Database end end + private def configure_micrate_connection(pg_db, pg_host, pg_port, pg_user, pg_password) + pg_user = "postgres" if pg_user.nil? + pg_password = "" if pg_password.nil? + Micrate::DB.connection_url = "postgresql://#{pg_user}:#{pg_password}@#{pg_host}:#{pg_port}/#{pg_db}" + end + + def pg_migrate_down( + pg_db : String, + pg_host : String, + pg_port : Int32, + pg_user : String? = nil, + pg_password : String? = nil, + target : Int64? = nil, + ) + configure_micrate_connection(pg_db, pg_host, pg_port, pg_user, pg_password) + Micrate::DB.connect do |db| + if target.nil? + Micrate.down(db) + else + # Public API only exposes one-step `down`; loop until at or below target. + loop do + current = Micrate.dbversion(db) + break if current <= target + Micrate.down(db) + break if Micrate.dbversion(db) == current + end + end + end + end + + def pg_migrate_redo( + pg_db : String, + pg_host : String, + pg_port : Int32, + pg_user : String? = nil, + pg_password : String? = nil, + ) + configure_micrate_connection(pg_db, pg_host, pg_port, pg_user, pg_password) + Micrate::DB.connect do |db| + Micrate.redo(db) + end + end + + def pg_migration_status( + pg_db : String, + pg_host : String, + pg_port : Int32, + pg_user : String? = nil, + pg_password : String? = nil, + ) + configure_micrate_connection(pg_db, pg_host, pg_port, pg_user, pg_password) + Micrate::DB.connect do |db| + puts "Applied At Migration" + puts "=======================================" + Micrate.migration_status(db).each do |migration, migrated_at| + ts = migrated_at.nil? ? "Pending" : migrated_at.to_s + puts "%-24s -- %s" % [ts, migration.name] + end + end + end + + def pg_database_version( + pg_db : String, + pg_host : String, + pg_port : Int32, + pg_user : String? = nil, + pg_password : String? = nil, + ) + configure_micrate_connection(pg_db, pg_host, pg_port, pg_user, pg_password) + Micrate::DB.connect do |db| + puts Micrate.dbversion(db) + end + end + def drop_pg_tables( pg_db : String, pg_host : String,