All demos/ Rails adoption
DEMO-06-RLSSEP 2026
Retrofit renders the migrations the linter asks for. Rails runs them, forward and back.
A Rails polymorphic association is a class name in a string column beside an id that points at more than one table, and no foreign key can hold it. When we want foreign keys on a populated table, strong_migrations stops the one-migration version and hands back five to write by hand, with a rollback nothing checks. We author that recipe as operations, Retrofit renders each migration forward and in reverse, and Rails runs them the way it runs everything.
- Stack
- Rails 8.1 + strong_migrations 2.8 + PostgreSQL 17
- Change
- a polymorphic pair becomes two foreign keys and an exclusive-arc check
- Reverse
- generated for the columns and constraints; exits 1 for the drop, and the best-effort reverse runs on
db:rollback
Chapters
db/structure.sql. import --ddl-only --seal reads that file into an operation log beside it, and identify reads it back.
02
Give the notes table foreign keys, in the steps the linter asks for2 min 01 secstrong_migrations refuses the one-migration version. We author its recipe as operations, Retrofit renders three migrations forward and in reverse, and the validate finds the note whose order was destroyed.
03
Drop the polymorphic pair, then roll it back1 min 34 secstrong_migrations refuses remove_reference. The index drops in its own window, the pair drops with its cost on screen, migrate --down --strict exits 1, and db:rollback runs the best-effort reverse that says what it cannot restore.
04
Run it end to end4 min 47 secAdopt db/structure.sql, let the linter refuse the short version, author its recipe as operations, and let Rails run five rendered migrations, the validate that finds the orphan, and the rollback that says what it cannot restore.Summary
You run Rails on PostgreSQL, and at least one table has a _type column beside its _id.
belongs_to :notable, polymorphic: true is the form the Rails
Guides teach, and
in PostgreSQL it is a class name in a string column beside an id that points at
two tables. No foreign key can hold that, so an order destroyed outside a
callback leaves its note behind and nothing reports it. The refactor the Rails
community recommends is one nullable foreign key per parent and a check that
exactly one is set. Written as one migration, strong_migrations stops it at
add_foreign_key, and it is right: on a populated table the key checks every
row while it blocks writes to both tables. Its recipe is NOT VALID then a
separate validate, one pair per constraint, and ignored_columns deployed
before any drop. That is five migrations by hand, and once one of them runs
raw SQL through execute, Rails can no longer work out its rollback. The debt
stays because the recipe is long and the reverse is unchecked.
--strict for the drop
because PostgreSQL cannot restore a dropped column’s values, and reads the
db/structure.sql Rails writes after each deploy to confirm the database and
the log agree. Rails keeps the transaction, schema_migrations, and the
file it already writes. What changes is where the SQL comes from.These five migrations carry rendered SQL inside safety_assured instead of
the DSL, so Rails cannot infer their rollback and it comes from the log
instead: clean for the columns and the constraints, an explicit no-op for the
validates, and for the drop the reverse Rails would have run in silence,
marked -- irreversible: on each line. Retrofit never connects to the
database: the backfill is two UPDATE statements we wrote, the orphan is a
decision we made, and db/structure.sql is the only file it reads. Below are
the migration classes Rails ran; the index drop’s rollback is the one line
Retrofit could not render for us.
AddNoteParents
class AddNoteParents < ActiveRecord::Migration[8.1]
def up
safety_assured { execute File.read(Rails.root.join("db/retrofit/add_parents.up.sql")) }
safety_assured { execute File.read(Rails.root.join("db/retrofit/backfill.sql")) }
end
def down
safety_assured { execute File.read(Rails.root.join("db/retrofit/add_parents.down.sql")) }
end
end
ConstrainNoteParents
class ConstrainNoteParents < ActiveRecord::Migration[8.1]
def up
safety_assured { execute File.read(Rails.root.join("db/retrofit/constrain.up.sql")) }
end
def down
safety_assured { execute File.read(Rails.root.join("db/retrofit/constrain.down.sql")) }
end
end
ValidateNoteParents
class ValidateNoteParents < ActiveRecord::Migration[8.1]
def up
safety_assured { execute File.read(Rails.root.join("db/retrofit/validate.up.sql")) }
end
def down
safety_assured { execute File.read(Rails.root.join("db/retrofit/validate.down.sql")) }
end
end
DropNotableIndex
class DropNotableIndex < ActiveRecord::Migration[8.1]
def up
safety_assured { execute File.read(Rails.root.join("db/retrofit/drop_index.up.sql")) }
end
def down
# Retrofit renders no reverse for an index drop yet, in either mode.
add_index :notes, [:notable_type, :notable_id], name: "index_notes_on_notable"
end
end
DropNotablePair
class DropNotablePair < ActiveRecord::Migration[8.1]
def up
safety_assured { execute File.read(Rails.root.join("db/retrofit/drop_pair.up.sql")) }
end
def down
safety_assured { execute File.read(Rails.root.join("db/retrofit/drop_pair.down.sql")) }
end
end
Chapter 01Start from the file Rails already commits
Can Retrofit start from `db/structure.sql` without replaying `db/migrate`?
A Rails app whose notes belong to orders and to products through belongs_to :notable, polymorphic: true. In db/structure.sql that is notable_type, a string holding a class name, and notable_id, an id that points at two tables, with an index on the pair and no foreign key. retrofit init and schema create give the log a home in db/, import --ddl-only --seal reads the DDL Rails wrote into it and seals that as the log’s first version, leaving the schema_migrations rows to Rails, and identify --expect last reads the same file back and confirms the two agree. Nothing was replayed, and nothing ran against the database.
Read transcript
rails-adopt-retrofit-demo: adopt
Generated from the demo script. Every command below is run verbatim by the recording.
1/2 the pair the Guides gave us
a Rails app. notes belong to orders and to products through belongs_to :notable, polymorphic: true, the way the Guides show it. in the database that is a class name in a string column and an id that points at two tables, so no foreign key can hold it:
grep -n notable db/structure.sql
an order gets destroyed, and a note whose order is gone stays put. we want foreign keys, on a populated table, without stopping the app. the file to start from is one Rails already commits: db/structure.sql, which pg_dump writes on every migrate.
2/2 the log, beside structure.sql
retrofit init -q
retrofit schema create public db --is-default -q
import reads the DDL in that file into an operation log next to it and seals it as the log’s first version. the schema_migrations rows are Rails’s ledger, and –ddl-only leaves them to Rails. nothing is replayed and nothing runs against the database:
retrofit import db/structure.sql --ddl-only --seal -q | tail -1
identify reads the same file back against the log:
retrofit identify db/structure.sql --expect last -q
t.references :notable, polymorphic: truebecomes a string holding a class name, an id that points at whichever table the string names, and an index on the pair. No foreign key can hold that, so an order destroyed withoutdependent: :destroyleaves its note behind.retrofit initandschema create public dbput the log atdb/public.oplog, andimport --ddl-only --sealreads the DDL Rails wrote into it as the first version, leaving theschema_migrationsinsert to Rails.identify --expect lastreads the same file back and exits 0.
Chapter 02Give the notes table foreign keys, in the steps the linter asks for
What does the linter's `safety_assured` block have to be sure about?
Written the way the Rails Guides teach, the refactor is one migration, and strong_migrations stops it at add_foreign_key: on a populated table that key checks every row while it blocks writes to both tables, and the gem’s recipe is add NOT VALID, validate separately, one pair per constraint. We author that recipe as operations, one seal per migration: the two columns, then the three constraints NOT VALID, then the three validates. migrate renders each window forward and in reverse; the reverse of the validates runs nothing, and the file says so, because PostgreSQL cannot unvalidate a constraint. The backfill is two UPDATE statements we write. Three migration classes run the rendered files inside safety_assured, and db:migrate fails on the first validate with the database’s own line: Key (order_id)=(4) is not present in table "orders". That is the note Rails could not see. We find it with one query, delete it, and the second db:migrate passes. identify reads the db/structure.sql Rails regenerated and exits 0.
Read transcript
rails-adopt-retrofit-demo: parents
Generated from the demo script. Every command below is run verbatim by the recording.
1/6 the migration we would write
the shape the Rails community recommends instead: one nullable foreign key per parent, and a check that exactly one is set. as one migration, the way the Guides teach, it reads like this:
cat db/migrate/20260920000002_constrain_note_parents.rb
strong_migrations is in the Gemfile, and it stops it:
docker compose run --rm web bin/rails db:migrate
exit status 1
the gem is right. on a populated table that key checks every row while it blocks writes to both tables. its recipe is add NOT VALID, validate in a separate migration, one pair per constraint, and a backfill before any of it. we author that recipe as operations and let Retrofit render the SQL.
2/6 the columns, and the backfill
retrofit op add AddColumn notes order_id bigint -q
retrofit op add AddColumn notes product_id bigint -q
retrofit op seal --reason AddNoteParents -q
one seal per migration. migrate renders the window forward and in reverse. Rails opens the transaction, so the envelope is the caller’s:
retrofit migrate --from seal~1 --to seal --envelope caller --allow-external-atomicity -q > db/retrofit/add_parents.up.sql
retrofit migrate --from seal~1 --to seal --envelope caller --allow-external-atomicity --down --strict -q > db/retrofit/add_parents.down.sql
grep -h '^ALTER' db/retrofit/add_parents.up.sql db/retrofit/add_parents.down.sql
the backfill is data, and it is ours to write:
cp ../inputs/backfill.sql db/retrofit/backfill.sql
cat db/retrofit/backfill.sql
3/6 the constraints, NOT VALID
each constraint is added NOT VALID. PostgreSQL defers the scan of the existing rows to a later VALIDATE, and the lock it takes here is brief:
retrofit op add AddForeignKey notes --column notes.order_id referenced_table=orders --referenced_column orders.id name=notes_order_id_fkey not_valid=true -q
retrofit op add AddForeignKey notes --column notes.product_id referenced_table=products --referenced_column products.id name=notes_product_id_fkey not_valid=true -q
retrofit op add AddCheck notes 'num_nonnulls(order_id, product_id) = 1' notes_one_parent not_valid=true -q
retrofit op seal --reason ConstrainNoteParents -q
retrofit migrate --from seal~1 --to seal --envelope caller --allow-external-atomicity -q > db/retrofit/constrain.up.sql
retrofit migrate --from seal~1 --to seal --envelope caller --allow-external-atomicity --down --strict -q > db/retrofit/constrain.down.sql
grep -h '^ALTER' db/retrofit/constrain.up.sql db/retrofit/constrain.down.sql
4/6 the validates
the validates are the third migration. each one scans the table once, under a lock that blocks neither reads nor writes while it does:
retrofit op add ValidateConstraint notes_order_id_fkey -q
retrofit op add ValidateConstraint notes_product_id_fkey -q
retrofit op add ValidateConstraint notes_one_parent -q
retrofit op seal --reason ValidateNoteParents -q
retrofit migrate --from seal~1 --to seal --envelope caller --allow-external-atomicity -q > db/retrofit/validate.up.sql
retrofit migrate --from seal~1 --to seal --envelope caller --allow-external-atomicity --down --strict -q > db/retrofit/validate.down.sql
PostgreSQL cannot unvalidate a constraint, so the reverse of this window runs nothing, and the file says what state it leaves:
grep -h '^ALTER\|^-- reverse-noop: state' db/retrofit/validate.up.sql db/retrofit/validate.down.sql
5/6 Rails runs them
three migration classes, one per seal. each runs its rendered file in the transaction Rails opens, inside safety_assured: the gem cannot read what execute runs, so it asks us to attest to it:
cp ../inputs/2026092000000[123]_*.rb db/migrate/
cat db/migrate/20260920000001_add_note_parents.rb
docker compose run --rm web bin/rails db:migrate
exit status 1
the first two ran. the validate did not: the database found a note whose order does not exist, and named it.
6/6 the note whose order is gone
this is the row Rails could not see. order 4 was destroyed, and nothing told its note. an order that no longer exists has no note to keep:
psql "$DB" -c "SELECT id, order_id, body FROM notes WHERE order_id NOT IN (SELECT id FROM orders)"
psql "$DB" -c "DELETE FROM notes WHERE order_id NOT IN (SELECT id FROM orders)"
docker compose run --rm web bin/rails db:migrate
Rails regenerated db/structure.sql. identify reads it against the log:
retrofit identify db/structure.sql --expect last -q
The exclusive arc as one migration: two
add_column, twoadd_foreign_key, oneadd_check_constraint.strong_migrationsstops it at the first foreign key and prints the recipe it would accept, the key withvalidate: falseand the validate in a separate migration.Two
AddColumnand a seal, one seal per Rails migration;migraterenders the window forward and, with--down --strict, its clean reverse. The backfill is data, so it is twoUPDATEstatements we wrote, and Rails runs it in the same migration.Two foreign keys and the check, each
not_valid=true: PostgreSQL skips the scan of existing rows and enforces the constraint for new ones. The reverse of this window is threeDROP CONSTRAINT, rendered clean.Three
ValidateConstraint, sealed as the third migration; each scans the table once under a lock that blocks neither reads nor writes. PostgreSQL cannot unvalidate a constraint, so the reverse is comment lines and no SQL, and it says so.Each class runs its rendered file with
executeinsidesafety_assured: the gem cannot read raw SQL, so it asks us to attest to a file we did not author.db:migrateruns the first two and fails on the validate, with the database naming the row.Order 4 was destroyed and nothing told its note, so we delete it; the query and the decision are ours.
db:migratepasses, Rails regeneratesdb/structure.sql, andidentify --expect lastreads it against the log and exits 0.
Chapter 03Drop the polymorphic pair, then roll it back
What does the reverse Rails runs on `db:rollback` actually do?
The notes table has foreign keys, and nothing reads notable_type or notable_id. Rails’s one-liner for that is remove_reference, and strong_migrations refuses it: Active Record caches columns, so the model has to ignore the pair and be deployed first. That deploy is ours, and the model is on screen. The pair’s index is a live reference, so DropIndex goes in its own window; Retrofit has no reverse for an index drop yet, so that migration’s down is the one line Rails already knows. Each DropColumn prints what it costs as it is authored. migrate --down --strict exits 1 on the window and names both columns: PostgreSQL cannot restore a dropped column’s values, so there is no reverse to stand behind. Without --strict, migrate renders the reverse it can, with an -- irreversible: marker on each line. Rails runs the drop, db:rollback runs that reverse, and the columns come back empty, which the file said before it ran. Forward again, and the db/structure.sql Rails regenerated has two foreign keys, one check, and no class name in it.
Read transcript
rails-adopt-retrofit-demo: drop
Generated from the demo script. Every command below is run verbatim by the recording.
1/4 the columns nothing reads
the notes have real parents now. the class name and the id it went with are still there, and nothing reads them. Rails has a line for that, and the gem stops it too:
cat db/migrate/20260920000005_drop_notable_pair.rb
docker compose run --rm web bin/rails db:migrate
exit status 1
Active Record caches columns, so a column dropped under a running app raises until the app restarts. the gem wants the model to ignore the pair first, and that deploy comes before this migration. Rails’s own rollback of that line would put both columns back, nullable and empty, and say nothing. the model:
cp ../inputs/models/note.after.rb app/models/note.rb
cat app/models/note.rb
2/4 the index goes first
the pair’s index is a live reference, so it goes in its own window, and the drop of the columns can only follow it:
retrofit op add DropIndex index_notes_on_notable -q
retrofit op seal --reason DropNotableIndex -q
retrofit migrate --from seal~1 --to seal --envelope caller --allow-external-atomicity -q > db/retrofit/drop_index.up.sql
Retrofit renders no reverse for an index drop yet, in either mode, so that migration’s down is the one line Rails already knows:
cp ../inputs/20260920000004_drop_notable_index.rb db/migrate/
cat db/migrate/20260920000004_drop_notable_index.rb
3/4 drop the pair
each drop is recorded with what it costs:
retrofit op add DropColumn notes.notable_type -q
retrofit op add DropColumn notes.notable_id -q
retrofit op seal --reason DropNotablePair -q
retrofit migrate --from seal~1 --to seal --envelope caller --allow-external-atomicity -q > db/retrofit/drop_pair.up.sql
cat db/retrofit/drop_pair.up.sql
with –strict, migrate renders no reverse it cannot stand behind, and says why:
retrofit migrate --from seal~1 --to seal --envelope caller --allow-external-atomicity --down --strict -q > db/retrofit/drop_pair.down.sql
exit status 1
without it, the reverse it can render, with what it cannot restore marked:
retrofit migrate --from seal~1 --to seal --envelope caller --allow-external-atomicity --down -q > db/retrofit/drop_pair.down.sql
grep -h '^ALTER\|^-- irreversible' db/retrofit/drop_pair.down.sql
cp ../inputs/20260920000005_drop_notable_pair.rb db/migrate/
Rails runs both, the index drop first:
docker compose run --rm web bin/rails db:migrate
4/4 rollback, and forward again
db:rollback runs that reverse. the columns come back and none of their values do, which is what the file said before it ran:
docker compose run --rm web bin/rails db:rollback
psql "$DB" -c "SELECT count(*) AS notes, count(notable_type) AS with_type FROM notes"
docker compose run --rm web bin/rails db:migrate
the file Rails regenerated has two foreign keys, one check, and no class name in it:
grep -n 'notable\|fkey\|notes_one_parent' db/structure.sql
retrofit identify db/structure.sql --expect last -q
remove_reference :notes, :notable, polymorphic: trueis one line, and the gem stops it: Active Record caches columns, so the model has to ignore the pair and be deployed first. Rails’s own rollback of that line would put both columns back empty and say nothing.DropColumnon a column with a live index exits 1 withrule-no-live-references, soDropIndexgoes first, in its own window. Retrofit has no reverse for an index drop yet, so that migration’sdownis theadd_indexline Rails already knows.Each
DropColumnprintsdata-loss-on-applyas it is authored, andmigrate --down --strictexits 1 naming both columns. Without--strict, the reverse is twoADD COLUMN ... DEFAULT NULLunder-- irreversible:markers, and that file is thedownRails will run.db:rollbackruns the rendered reverse: four notes, none with anotable_type, which the file said before it ran. Forward again, the regenerateddb/structure.sqlhas two foreign keys, one check, and nonotablein it, andidentify --expect lastexits 0.
Chapter 04Run it end to end
Start from the polymorphic pair in db/structure.sql and adopt it into an operation log. Watch strong_migrations refuse add_foreign_key, author the columns, the NOT VALID constraints and the validates as three sealed windows, and let Rails run them until the validate finds the note whose order was destroyed. Delete it, pass, then watch the gem refuse remove_reference, drop the index and the pair, let --strict exit 1 on the reverse, and run the best-effort one with db:rollback before going forward again.
Read transcript
rails-adopt-retrofit-demo: complete walkthrough
Generated from the demo script. Every command below is run verbatim by the recording.
the pair the Guides gave us
a Rails app. notes belong to orders and to products through belongs_to :notable, polymorphic: true, the way the Guides show it. in the database that is a class name in a string column and an id that points at two tables, so no foreign key can hold it:
grep -n notable db/structure.sql
an order gets destroyed, and a note whose order is gone stays put. we want foreign keys, on a populated table, without stopping the app. the file to start from is one Rails already commits: db/structure.sql, which pg_dump writes on every migrate.
the log, beside structure.sql
retrofit init -q
retrofit schema create public db --is-default -q
import reads the DDL in that file into an operation log next to it and seals it as the log’s first version. the schema_migrations rows are Rails’s ledger, and –ddl-only leaves them to Rails. nothing is replayed and nothing runs against the database:
retrofit import db/structure.sql --ddl-only --seal -q | tail -1
identify reads the same file back against the log:
retrofit identify db/structure.sql --expect last -q
the migration we would write
the shape the Rails community recommends instead: one nullable foreign key per parent, and a check that exactly one is set. as one migration, the way the Guides teach, it reads like this:
cat db/migrate/20260920000002_constrain_note_parents.rb
strong_migrations is in the Gemfile, and it stops it:
docker compose run --rm web bin/rails db:migrate
exit status 1
the gem is right. on a populated table that key checks every row while it blocks writes to both tables. its recipe is add NOT VALID, validate in a separate migration, one pair per constraint, and a backfill before any of it. we author that recipe as operations and let Retrofit render the SQL.
the columns, and the backfill
retrofit op add AddColumn notes order_id bigint -q
retrofit op add AddColumn notes product_id bigint -q
retrofit op seal --reason AddNoteParents -q
one seal per migration. migrate renders the window forward and in reverse. Rails opens the transaction, so the envelope is the caller’s:
retrofit migrate --from seal~1 --to seal --envelope caller --allow-external-atomicity -q > db/retrofit/add_parents.up.sql
retrofit migrate --from seal~1 --to seal --envelope caller --allow-external-atomicity --down --strict -q > db/retrofit/add_parents.down.sql
grep -h '^ALTER' db/retrofit/add_parents.up.sql db/retrofit/add_parents.down.sql
the backfill is data, and it is ours to write:
cp ../inputs/backfill.sql db/retrofit/backfill.sql
cat db/retrofit/backfill.sql
the constraints, NOT VALID
each constraint is added NOT VALID. PostgreSQL defers the scan of the existing rows to a later VALIDATE, and the lock it takes here is brief:
retrofit op add AddForeignKey notes --column notes.order_id referenced_table=orders --referenced_column orders.id name=notes_order_id_fkey not_valid=true -q
retrofit op add AddForeignKey notes --column notes.product_id referenced_table=products --referenced_column products.id name=notes_product_id_fkey not_valid=true -q
retrofit op add AddCheck notes 'num_nonnulls(order_id, product_id) = 1' notes_one_parent not_valid=true -q
retrofit op seal --reason ConstrainNoteParents -q
retrofit migrate --from seal~1 --to seal --envelope caller --allow-external-atomicity -q > db/retrofit/constrain.up.sql
retrofit migrate --from seal~1 --to seal --envelope caller --allow-external-atomicity --down --strict -q > db/retrofit/constrain.down.sql
grep -h '^ALTER' db/retrofit/constrain.up.sql db/retrofit/constrain.down.sql
the validates
the validates are the third migration. each one scans the table once, under a lock that blocks neither reads nor writes while it does:
retrofit op add ValidateConstraint notes_order_id_fkey -q
retrofit op add ValidateConstraint notes_product_id_fkey -q
retrofit op add ValidateConstraint notes_one_parent -q
retrofit op seal --reason ValidateNoteParents -q
retrofit migrate --from seal~1 --to seal --envelope caller --allow-external-atomicity -q > db/retrofit/validate.up.sql
retrofit migrate --from seal~1 --to seal --envelope caller --allow-external-atomicity --down --strict -q > db/retrofit/validate.down.sql
PostgreSQL cannot unvalidate a constraint, so the reverse of this window runs nothing, and the file says what state it leaves:
grep -h '^ALTER\|^-- reverse-noop: state' db/retrofit/validate.up.sql db/retrofit/validate.down.sql
Rails runs them
three migration classes, one per seal. each runs its rendered file in the transaction Rails opens, inside safety_assured: the gem cannot read what execute runs, so it asks us to attest to it:
cp ../inputs/2026092000000[123]_*.rb db/migrate/
cat db/migrate/20260920000001_add_note_parents.rb
docker compose run --rm web bin/rails db:migrate
exit status 1
the first two ran. the validate did not: the database found a note whose order does not exist, and named it.
the note whose order is gone
this is the row Rails could not see. order 4 was destroyed, and nothing told its note. an order that no longer exists has no note to keep:
psql "$DB" -c "SELECT id, order_id, body FROM notes WHERE order_id NOT IN (SELECT id FROM orders)"
psql "$DB" -c "DELETE FROM notes WHERE order_id NOT IN (SELECT id FROM orders)"
docker compose run --rm web bin/rails db:migrate
Rails regenerated db/structure.sql. identify reads it against the log:
retrofit identify db/structure.sql --expect last -q
the columns nothing reads
the notes have real parents now. the class name and the id it went with are still there, and nothing reads them. Rails has a line for that, and the gem stops it too:
cat db/migrate/20260920000005_drop_notable_pair.rb
docker compose run --rm web bin/rails db:migrate
exit status 1
Active Record caches columns, so a column dropped under a running app raises until the app restarts. the gem wants the model to ignore the pair first, and that deploy comes before this migration. Rails’s own rollback of that line would put both columns back, nullable and empty, and say nothing. the model:
cp ../inputs/models/note.after.rb app/models/note.rb
cat app/models/note.rb
the index goes first
the pair’s index is a live reference, so it goes in its own window, and the drop of the columns can only follow it:
retrofit op add DropIndex index_notes_on_notable -q
retrofit op seal --reason DropNotableIndex -q
retrofit migrate --from seal~1 --to seal --envelope caller --allow-external-atomicity -q > db/retrofit/drop_index.up.sql
Retrofit renders no reverse for an index drop yet, in either mode, so that migration’s down is the one line Rails already knows:
cp ../inputs/20260920000004_drop_notable_index.rb db/migrate/
cat db/migrate/20260920000004_drop_notable_index.rb
drop the pair
each drop is recorded with what it costs:
retrofit op add DropColumn notes.notable_type -q
retrofit op add DropColumn notes.notable_id -q
retrofit op seal --reason DropNotablePair -q
retrofit migrate --from seal~1 --to seal --envelope caller --allow-external-atomicity -q > db/retrofit/drop_pair.up.sql
cat db/retrofit/drop_pair.up.sql
with –strict, migrate renders no reverse it cannot stand behind, and says why:
retrofit migrate --from seal~1 --to seal --envelope caller --allow-external-atomicity --down --strict -q > db/retrofit/drop_pair.down.sql
exit status 1
without it, the reverse it can render, with what it cannot restore marked:
retrofit migrate --from seal~1 --to seal --envelope caller --allow-external-atomicity --down -q > db/retrofit/drop_pair.down.sql
grep -h '^ALTER\|^-- irreversible' db/retrofit/drop_pair.down.sql
cp ../inputs/20260920000005_drop_notable_pair.rb db/migrate/
Rails runs both, the index drop first:
docker compose run --rm web bin/rails db:migrate
rollback, and forward again
db:rollback runs that reverse. the columns come back and none of their values do, which is what the file said before it ran:
docker compose run --rm web bin/rails db:rollback
psql "$DB" -c "SELECT count(*) AS notes, count(notable_type) AS with_type FROM notes"
docker compose run --rm web bin/rails db:migrate
the file Rails regenerated has two foreign keys, one check, and no class name in it:
grep -n 'notable\|fkey\|notes_one_parent' db/structure.sql
retrofit identify db/structure.sql --expect last -q
Rails taught the polymorphic association and the database could not hold it. we gave a populated table foreign keys in five migrations the running app survived, the validation found the note Rails never saw, and the reverse Rails ran was rendered from the same operations. Retrofit wrote SQL and read one file. Rails deployed.