Cremind
Contributing & Maintenance

Schema migrations

Generating, hand-reviewing, and shipping database migrations safely — autogenerate, the additive-then-destructive rule, and testing real upgrades.

When a PR touches the database schema — that is, app/storage/models.py — it needs a migration. Cremind uses Alembic, with the migration tree bundled inside the wheel at app/alembic/ so an installed copy can migrate itself without an alembic.ini on disk.

This page covers the workflow and the safety rules. Migrations are one of the few places where a mistake can corrupt user data, so the review bar is higher than for ordinary code.

The workflow

1. Generate the migration

cremind db revision --autogenerate -m "short_description"

This diffs the live schema against the models and scaffolds a migration under app/alembic/versions/.

2. Review the autogenerate output by hand

Autogenerate is a draft, not an answer

Alembic's autogenerate misses or mis-handles several common changes. Always read the generated migration and fix it before committing.

Autogenerate is known to get these wrong:

  • Column renames — it sees them as a drop plus an add, which loses the column's data. Rewrite as an actual rename.
  • server_default changes — often not detected.
  • Check constraints — frequently missed.
  • Enum additions — handled inconsistently across backends.
  • Index renames — emitted as drop plus create.

3. Prefer additive shapes within one release

Within a single release, keep migrations additive: add columns, add tables, add indexes. Split destructive changes across two releases so a rollback always has somewhere safe to land:

  • Release N — add the new shape and dual-write to both old and new.
  • Release N+1 — drop the old shape, once you're confident nothing reads it.

This way an upgrade to N is reversible, and N+1 only removes a column that's already unused.

4. Backfill inside the migration

Populate new columns inside the migration, not in application code. A migration runs exactly once at a known point in the upgrade; application-side backfill races with serving traffic and leaves rows in mixed states.

5. Test the upgrade from a real old install

Test the upgrade against a real older install, not just a fresh database. A fresh DB exercises the baseline-create path; it does not exercise the data your migration actually has to transform. Stand up an install at the prior version, populate it, then upgrade.

The upgrade floor

MIN_SUPPORTED_UPGRADE_FROM in app/__version__.py is the oldest version this build can migrate from. It's currently 0.0.0, which accepts any install. Bump it only when a migration genuinely requires a minimum prior schema version — and when you do, the upgrader will refuse to proceed on installs older than that, so it's a real compatibility break, not a routine change.

How it runs at install time

On startup the runtime brings the schema to head automatically: a fresh database runs the baseline migration to create the tables; an existing database runs any pending migrations and is a fast no-op if it's already at head. You don't run an upgrade command by hand on a normal install — the migration ships in the wheel and applies itself.

Next

On this page