Skip to content

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 table alembic_version).
  • Per-extension — an extension that owns tables ships its own chain as package data (ext_<name>/migrations/versions/) with a private version table alembic_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_* for excise_engine, chat_logs for jai_chat). The runner rejects create_table outside 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-extension env.py.
  • The runner (vectis.core.ext_migrations, re-exported as vectis_sdk.migrations) applies each chain at api boot under a Postgres advisory lock, recording the head in alembic_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 the check-ext-migration-ownership guard in make check fails any new core revision touching them.
  • Worker / schedule runner / event consumer boot with run_lifecycle=False and 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

  1. Descriptive namesadd_loyalty_points_table, not update_schema
  2. Always include downgrade — every upgrade() needs a matching downgrade()
  3. Test both directionsupgrade head, downgrade -1, upgrade head
  4. Server defaults for new NOT NULL columns — existing rows need a value
  5. 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:

python -m vectis.core.seed

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 migrate first
  • Empty migration — model module not imported in alembic/env.py
  • Merge conflictsalembic merge heads -m "merge_branches"
  • check-model-drift failure in make check — either generate the missing migration with make makemigration (then review and make 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