Database migrations¶
Alembic owns the schema. scripts/seed.py only fills it, and refuses to run against a database that
has not been migrated.
After changing a model¶
cd provider
uv run alembic revision --autogenerate -m "what changed"
# read what it produced
uv run alembic upgrade head
Read the migration before applying it
Autogenerate is reliable for added tables and columns and unreliable for anything it has to
infer. A renamed column looks like a drop plus an add — and the data in it disappears silently.
Server defaults and CHECK constraints are often missed entirely.
Every migration in this repository has been hand-corrected after generation. That is normal, not a sign something went wrong.
The correction autogenerate always needs¶
A NOT NULL column with no default. Autogenerate emits:
which fails against any database that already has rows. Three ways out, depending on what the column means:
op.add_column("refresh_tokens", sa.Column("authenticated_at", sa.DateTime(), nullable=True))
op.execute("UPDATE refresh_tokens SET authenticated_at = created_at")
op.alter_column("refresh_tokens", "authenticated_at", nullable=False)
For a token issued before the column existed, when it was created is the closest record of when its owner authenticated.
op.add_column("clients", sa.Column("flag", sa.Boolean(), nullable=False,
server_default=sa.false()))
op.alter_column("clients", "flag", server_default=None)
Existing rows get false; the default afterwards belongs to the model rather than the schema.
When there is genuinely nothing to backfill with. refresh_tokens.sid is nullable because those
sessions were never recorded — inventing a value would make a token look like it belonged to a
session that could be signed out.
The test that catches a forgotten revision¶
tests/test_migrations.py builds a database by running the migrations, then asks autogenerate what
is left to do. Anything it still wants is a revision nobody wrote.
That is why the test database is built by migrating, not by create_all: two ways to produce the
same schema is one too many, and this is the path a deployment takes.
A database that predates Alembic¶
Stamp the initial revision — the tables it creates already exist — then apply everything since:
Stamping head instead would claim every revision is applied and leave the tables the later ones
create missing.
Generated migrations are formatted¶
alembic.ini runs ruff check --fix and ruff format as post-write hooks, so a new revision never
arrives already failing the checks.