Alembic Migrations¶
Vectis uses Alembic for schema migrations against a single PostgreSQL schema. There are two kinds of migration chains:
- Core — all core modules share one migration history in
vectis/backend/alembic/versions/(version tablealembic_version). - Per-extension — an extension that owns tables ships its
own chain as package data (
ext_<name>/migrations/versions/) with a private version tablealembic_version_ext_<name>. See below.
Core Flow: make makemigration → review → make migrate¶
Generation and application are separate steps with a mandatory human review between them (split 2026-07-02):
make makemigration # autogenerate a revision inside the api container — does NOT apply it
# review/edit backend/alembic/versions/<newest file>
make migrate # apply pending revisions (alembic upgrade head only, no generation)
Always review the generated file — autogenerate misses data migrations, custom constraints, and partial indexes. If the generated diff reflects DDL the migration history already created, fix the model instead and delete the revision.
Both targets run Alembic inside the vectis-api-1 container, so host Alembic
is not assumed on PATH. Raw alembic commands still work inside the container:
alembic upgrade +1 # one revision at a time
alembic current # check current revision
alembic history --verbose # show history
Warning
Import every model module in alembic/env.py so Base.metadata includes
all tables. Missing imports produce empty migrations.
Per-Extension Migrations¶
Extension-owned tables do not go through the core chain:
- Tables must use the
ext_<name>_prefix (grandfathered exceptions:ee_*forexcise_engine,chat_logsforjai_chat). The runner rejectscreate_tableoutside the extension's namespace. - Revisions are hand-written files in the extension's
migrations/versions/package data (e.g.ext_excise_engine/migrations/versions/ee0001_...py). There is no per-extensionenv.py. - The runner (
vectis.core.ext_migrations, re-exported asvectis_sdk.migrations) applies each chain at api boot under a Postgres advisory lock, recording the head inalembic_version_ext_<name>. If the tables already exist but the version table doesn't, it stamps instead of re-running DDL. - Core autogenerate excludes extension-owned tables via
include_object, and thecheck-ext-migration-ownershipguard inmake checkfails any new core revision touching them. - Worker / schedule runner / event consumer boot with
run_lifecycle=Falseand fail fast when an extension's recorded schema revision doesn't match its packaged head — only the api migrates.
See Building Extensions for the extension-author view.
Conventions¶
- Descriptive names —
add_loyalty_points_table, notupdate_schema - Always include downgrade — every
upgrade()needs a matchingdowngrade() - Test both directions —
upgrade head,downgrade -1,upgrade head - Server defaults for new NOT NULL columns — existing rows need a value
- BigInteger PKs — all new tables must use
sa.BigInteger()primary keys
Example: Multi-Currency Migration¶
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
def upgrade() -> None:
op.add_column('channels', sa.Column(
'supported_currencies', postgresql.JSONB(), nullable=True, server_default='["USD"]',
))
op.create_table('exchange_rates',
sa.Column('id', sa.BigInteger(), primary_key=True),
sa.Column('base_currency', sa.String(3), nullable=False, index=True),
sa.Column('target_currency', sa.String(3), nullable=False, index=True),
sa.Column('rate', sa.Numeric(18, 8), nullable=False),
sa.Column('effective_from', sa.DateTime(timezone=True), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()')),
sa.UniqueConstraint('base_currency', 'target_currency', 'effective_from',
name='uq_er_pair_effective'),
)
op.add_column('orders', sa.Column(
'currency', sa.String(3), nullable=False, server_default='USD',
))
def downgrade() -> None:
op.drop_column('orders', 'currency')
op.drop_table('exchange_rates')
op.drop_column('channels', 'supported_currencies')
Note
When adding NOT NULL columns to existing tables, always include
server_default so existing rows are populated automatically.
Seeding After Migrations¶
vectis/core/seed.py creates the default channel, roles, permissions, and an
admin user. Run after applying migrations to a fresh database:
The seed is idempotent — safe to run on a populated database.
Rolling Back¶
alembic downgrade -1 # revert last migration
alembic downgrade a3e2f1b8c9d4 # revert to specific revision
alembic downgrade base # revert everything
Warning
Downgrade in production requires caution. Dropping columns/tables is irreversible once committed. Always back up first.
Model-Drift Guard¶
make check runs check-model-drift: it builds a scratch database purely from
the migration chain and fails when alembic check (autogenerate) is non-empty
against it — i.e. when the SQLAlchemy metadata and the migration history
disagree. Because the scratch DB comes from migrations alone, seed-path
create_all can't mask a forgotten migration. On a fully-migrated database,
autogenerate is empty — anything make makemigration emits is your change.
The guard is part of every local check; CI is currently disabled in favor of running make check inside the API container before pushing.
Troubleshooting¶
- "Target database is not up to date" — run
make migratefirst - Empty migration — model module not imported in
alembic/env.py - Merge conflicts —
alembic merge heads -m "merge_branches" check-model-driftfailure inmake check— either generate the missing migration withmake makemigration(then review andmake migrate) or fix the model so it matches the migration history. Don't bypass the gate.
Notable Recent Migrations¶
| Revision | Purpose |
|---|---|
zen5o6p7q8r9 |
Packaging UoM ladder, products.track_inventory, MMOQ caps on product_variants |
zfo6p7q8r9s0 |
MMOQ display unit |
zir9s0t1u2v3 |
Channel timezone for 30-day MMOQ window |
zjs0t1u2v3w4 |
Variant.external_stock_snapshot for untracked products |
zkt1u2v3w4x5 |
OrderLineItem.tracking_enabled_at_checkout snapshot |
zlu2v3w4x5y6 |
Product.inventory_state_version race guard |
zmv3w4x5y6z7 |
product_packages.sku for cart bulk lookup |
zqz7a8b9c0d1 |
Order external-fulfillment handoff columns |
zra8b9c0d1e2 |
CartRejectionEvent audit table |
zsb9c0d1e2f3 |
cart_approved_blocked_inventory status enum value (B2B inventory revalidation gate) |
ztc0d1e2f3g4 |
Cart.cart_approved_grand_total (pricing-drift gate) |
zud1e2f3g4h5 |
payment_tender.source provenance column |