Development

ForgeSchema Builds Indexes Online and Plans Drops and Type Changes

ForgeSchema 0.3.0 is out. It’s the first release that isn’t labelled alpha, and the first one that went out on its own: bump the version, push, and CI builds, checks and publishes it once the tests pass on PostgreSQL 16.10 and 17.6.

Three things changed. Indexes can be built without blocking writes. Check constraints are understood. And drops and type conversions are finally possible, but only when you ask for them by name.

ForgeSchema workflow

Indexes without the write lock

A plain CREATE INDEX on a big table blocks writes until it’s done. Run forgeschema plan --online and new indexes and unique constraints on existing tables are built with CREATE INDEX CONCURRENTLY instead. Reads and writes carry on.

That can’t happen inside one transaction, so an online plan runs in three phases: a transaction for the table and column changes, then each index build on its own, then a short final transaction that attaches unique constraints, adds foreign keys, checks the whole result and writes history. plan --explain shows the split.

Builds fail sometimes. Duplicates under a new unique index are the usual reason, and PostgreSQL leaves a broken index behind when that happens. Fix the data and run the same plan again. ForgeSchema sees the first phase is already done, drops the broken index, builds it again and finishes. If anything else changed in the meantime it stops and says so.

Check constraints

Rules like total >= 0, status IN ('active', 'closed') or char_length(sku) = 12 now round-trip through inspect, plan, apply and verify. The grammar is small on purpose: comparisons with literals, lists or another column, joined by AND. PostgreSQL prints these back with extra casts and brackets, and ForgeSchema strips only the casts that can’t change a value. Anything else gets rejected rather than guessed at. The shop example has four.

Drops and type changes, by name only

Taking a column out of the target used to block the plan, full stop. It still does, unless the change file lists it:

version: 1
drops:
- table: order_lines
  column: quantity
conversions:
- table: orders
  column: code

Each drop has to be spelled out, including any index or constraint that goes with a dropped column. A dropped column or table gets an explanation that says the data is gone for good, and --explain opens with a list of everything the plan deletes. Conversions run ALTER COLUMN ... TYPE ... USING, and only for type pairs whose result depends on the stored value alone. Timestamp to date, which depends on the session time zone, is blocked. So are integer to boolean and anything that would cut text short without an error.

A rename can now reuse the name of a column dropped in the same plan. That’s what you need for the careful way to change a type on a busy table: add a new column, backfill it, then drop the old one and rename the new one into its place. PLANNING.md walks through it, and a test runs exactly that through the CLI.

Found in review

The independent review before release found real problems. An online build and a second apply waiting for its turn could deadlock, because the waiter held a database snapshot the build was waiting on. The default 5-second lock timeout could also kill a build that was just waiting on an old transaction. Two kinds of drop passed planning and then failed in PostgreSQL. And a few conversions lost data quietly. All fixed, each with a test. It also turned up an older bug: widening a varchar column with a default to text always failed. Fixed too.

Plans saved by earlier versions still load and apply unchanged. Binaries are on the download page, which now always points at the latest release.