Migrations
austial.migrations wires Alembic to austial.orm’s
DataSource, so a product can generate and apply schema migrations without leaving the
framework. Alembic runs against the same SQLAlchemy MetaData the ORM builds from your
@Entity() classes — DataSource.get_metadata() is the single documented seam where
that metadata crosses the ORM’s public API boundary (see Database
for the ORM itself).
The framework ships:
MigrationsModule.for_root(...)— dynamic module that registersMigrationRunnerin the DI container.MigrationRunner— an injectable service withrevision,upgrade,downgrade,currentandhistorymethods.alembic_env(config, metadata)— the online/offline runner yourenv.pydelegates to, so upgrading Alembic itself never forces aenv.pyregeneration.austial-migrate— a standalone Typer CLI (revision,upgrade,downgrade,current,history,init) that runs without booting the DI container, so migrations can happen before the application imports cleanly at deploy time.
Setup#
Install the package (already there if you use austial as an umbrella dependency,
otherwise uv add austial-migrations) and register the module:
from austial import Module
from austial.migrations import MigrationsConfig, MigrationsModule
from austial.orm import DataSource
from src.entities import Cat, Owner
_metadata = DataSource(...).get_metadata() # cheap: no connection opens
@Module(
imports=[
MigrationsModule.for_root(
MigrationsConfig(script_location="alembic"),
url="postgresql+psycopg://user:pass@localhost/mydb",
metadata=_metadata,
),
],
)
class AppModule:
passMigrationsConfig accepts:
script_location— on-disk directory holdingenv.py+versions/(default"alembic").version_table— Alembic bookkeeping table (default"alembic_version").compare_type— pass through to Alembic’s autogenerate (defaultTrue).compare_server_default— same (defaultTrue).
MigrationsModule is registered @Global() by default; any module can then inject
MigrationRunner.
Bootstrapping the alembic/ directory#
Once, per project:
austial-migrate initWrites an env.py, a script.py.mako, an empty versions/ folder and a short README
at script_location. The generated env.py is a one-line delegation to
austial.migrations.alembic_env, so future framework-side changes to how migrations run
online/offline never require regenerating it.
Its target_metadata comes from context.config.attributes["target_metadata"] — the
runner sets this before invoking Alembic, and the CLI resolves it from the
AUSTIAL_METADATA_MODULE env var (see below).
Generating and applying migrations#
From inside the application (typical during local development):
from austial import Injectable
from austial.migrations import MigrationRunner
@Injectable()
class SchemaAdmin:
def __init__(self, runner: MigrationRunner):
self._runner = runner
def bump(self, message: str) -> str:
return self._runner.revision(message, autogenerate=True)
def deploy(self) -> None:
self._runner.upgrade("head")From a shell (the standard deploy path):
export AUSTIAL_DATABASE_URL="postgresql+psycopg://user:pass@localhost/mydb"
export AUSTIAL_METADATA_MODULE="src.app_module:target_metadata"
austial-migrate revision -m "add users table" --autogenerate
austial-migrate upgrade head
austial-migrate current
austial-migrate history
austial-migrate downgrade -1The CLI reads its URL from, in order: an explicit --url flag, the
AUSTIAL_DATABASE_URL env var, or sqlalchemy.url in a local alembic.ini. For
autogenerate, the CLI additionally imports target_metadata from the dotted path in
AUSTIAL_METADATA_MODULE ("my_pkg.module:attribute_name"); every other subcommand
works without it.
env.py template#
The generated env.py is deliberately short — the framework owns everything below the
alembic_env(...) call:
from __future__ import annotations
from logging.config import fileConfig
from alembic import context
from austial.migrations import alembic_env
target_metadata = context.config.attributes.get("target_metadata")
if context.config.config_file_name is not None:
try:
fileConfig(context.config.config_file_name)
except Exception:
pass
alembic_env(context.config, target_metadata)If you want to write env.py yourself (e.g. because you already have a heavily
customised Alembic setup), just call alembic_env(context.config, your_metadata) at the
bottom and the framework will handle online/offline, NullPool engine construction, and
threading the version_table / compare_type / compare_server_default settings
through.
Autogenerate caveats#
Alembic’s autogenerate compares SQLAlchemy metadata against the current DB and writes its best guess into a new revision file. Well-known gaps every product should read before trusting it blindly:
- Rename detection: Alembic sees a rename as a drop + create. Edit the generated
revision to use
op.alter_column(...)/op.rename_table(...)before applying. - CHECK constraints and server defaults: often not inferred; the two
compare_*flags help but do not close the gap entirely. - Postgres
ENUMtypes: create/drop is emitted, but rename/reorder isn’t — hand-edit. - Data migrations: never inferred. Add
op.bulk_insert(...)or a plainop.execute()step yourself when the schema change needs backfill.
Always read the generated revision before committing it. Alembic’s own documentation
Autogenerate caveats
is the reference; nothing in austial.migrations changes that contract.
Change management (ISO/IEC 27002 A.8.32)#
austial.migrations is the schema-change control point ISO/IEC 27002 A.8.32
(“Change management”) expects a production system to have: every DDL change lives in a
version-controlled revision file, applied in order, reversibly, with a persisted
alembic_version bookmark. Pair it with the usual code-review workflow — one PR per
migration, reviewed by someone other than the author — and the audit trail A.8.32 asks
for falls out of standard git + CI, not a separate change-management system.
Never edit an already-applied revision file. Add a new revision that fixes it forward;
downgrade is a rollback tool for staging or a fresh incident, not a way to rewrite
history in place.