EDE Framework — Migrations¶
Overview¶
EDE uses Alembic for database migrations with a per-app version locations strategy. Each app owns its migration scripts. Migrations run per tenant (each tenant has its own database).
Directory Structure¶
src/
├── ede/foundation/
│ ├── base/migrations/versions/ ← foundation.base migrations
│ ├── auth/migrations/versions/ ← foundation.auth migrations
│ └── presentation/migrations/versions/ ← foundation.presentation migrations
│
└── domains/
└── logistics/
└── shipment/migrations/versions/ ← logistics.shipment migrations
└── 001_add_shipment.py
migrations/
└── env.py ← Alembic multi-tenant env.py
Each app has its own migrations/versions/ directory. Alembic is configured with
multiple version_locations pointing to all of these directories simultaneously.
CLI Commands¶
Generate a Migration¶
# Generate for all apps (autogenerate from model diff):
ede migrate generate -m "add shipment table"
# Generate for a single app only:
ede migrate generate -m "add weight field" --app logistics.shipment
This uses a reference SQLite database (.ede/migrations/ref.db) to compute the diff
between the current ORM model state and the DB state.
Upgrade a Tenant¶
# Upgrade a specific tenant to latest heads:
ede migrate upgrade -t acme
# Upgrade all registered tenants:
ede migrate upgrade --all
For PostgreSQL, ensure_database_exists() is called before running Alembic to create the
DB if it doesn't exist.
Downgrade¶
# Downgrade a specific app's migration by one step:
ede migrate downgrade -t acme --app logistics.shipment -1
How Alembic Knows Which App Owns Which Migration¶
SqlAlchemyMetadataBuilder stamps each Table object with:
When generating migrations, migrations/env.py uses this info to route new migration
scripts to the correct versions/ directory.
Multiple Heads¶
Because each app has its own migration chain, Alembic operates with multiple heads (one per app). This is normal and expected.
alembic heads
# → (head) abc123 (foundation.base)
# → (head) def456 (foundation.auth)
# → (head) ghi789 (logistics.shipment)
ede migrate upgrade -t acme runs alembic upgrade heads — upgrades all heads for
the given tenant.
Reference Database¶
To autogenerate migrations (compare model-to-DB diff), Alembic needs a reference database reflecting the current DB state. EDE uses a SQLite reference DB at:
This is rebuilt when you run ede migrate generate. It is committed to the repository
as a shared baseline so every developer (and CI) autogenerates migrations against the same
DB shape. After running ede migrate generate, commit the updated ref.db alongside the
new migration script.
SQLite and Batch Mode¶
EDE uses a SQLite reference database (.ede/migrations/ref.db) during ede migrate generate
to compute the model-vs-DB diff. SQLite does not support ALTER TABLE … ADD CONSTRAINT, so any
migration op that adds a foreign key or constraint to an existing table must use
op.batch_alter_table() (copy-and-move strategy):
# ✅ Correct — works on both SQLite (batch copy) and PostgreSQL
with op.batch_alter_table('res_user', schema=None) as batch_op:
batch_op.create_foreign_key(
op.f('fk_res_user_organization_id_res_organization'),
'res_organization',
['organization_id'], ['record_uuid'],
ondelete='restrict',
)
# ❌ Wrong — raises NotImplementedError on SQLite
op.create_foreign_key(
op.f('fk_res_user_organization_id_res_organization'),
'res_user', 'res_organization',
['organization_id'], ['record_uuid'],
)
ede migrate generate sets render_as_batch=True automatically for SQLite in
migrations/env.py, so autogenerated migrations always emit batch_alter_table.
If you write a migration manually and need to add a FK or constraint to an already-
existing table, always wrap the op in batch_alter_table.
The same rule applies to op.drop_constraint() in downgrade():
with op.batch_alter_table('res_user', schema=None) as batch_op:
batch_op.drop_constraint(
op.f('fk_res_user_organization_id_res_organization'),
type_='foreignkey',
)
Writing a Migration Manually¶
If autogenerate doesn't capture a change, write a migration manually:
# src/domains/logistics/shipment/migrations/versions/002_add_notes.py
from alembic import op
import sqlalchemy as sa
revision = '002_abc123'
down_revision = '001_xyz789'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
'logistics_shipment',
sa.Column('notes', sa.Text(), nullable=True),
)
def downgrade() -> None:
op.drop_column('logistics_shipment', 'notes')
Table name rule: Always use the EDE table name format (model_key with . → _).
Example: model logistics.shipment → table logistics_shipment.
Alembic env.py¶
src/migrations/env.py
The Alembic environment file handles:
1. Boot via boot_runtime_and_environment() — loads all apps, builds registry
2. Builds SqlAlchemyMetaData from all registered model classes (skip_audit_fks=True — see below)
3. Sets version_locations to all app migration paths (from registry.list_migration_version_locations())
4. Sets the connection URL based on --tenant CLI argument
5. Runs run_migrations_online() or run_migrations_offline()
skip_audit_fks=True¶
Every DomainModel automatically gets created_uid and updated_uid audit columns,
both of which are Reference("res.user") — i.e. FK columns pointing at res_user.
At the same time, res.user itself has an FK to res.organization, and res.organization
has an FK back to res.user (via those same audit columns). This creates a circular
dependency that SQLAlchemy cannot resolve when sorting tables for DDL generation:
res_user → res_organization (organization_id FK)
res_organization → res_user (created_uid / updated_uid FKs) ← cycle
res_country → res_user (created_uid / updated_uid FKs) ← cycle
skip_audit_fks=True tells SqlAlchemyMetadataBuilder to omit the SQLAlchemy
ForeignKey constraint objects for created_uid / updated_uid when building
MetaData. The columns themselves are still created — only the DB-level FK
constraints are dropped. Because audit columns are write-only references set from
the JWT principal, enforced FK constraints add no correctness guarantee and only
cause the sort cycle.
This flag is also used everywhere in the test suite for the same reason.
Per-App Migration Chain¶
Each app's migration chain is independent. Foundation apps are always migrated before
user-domain apps (deterministic app loading order ensures this). Within an app, migrations
are linear (each migration knows its down_revision).
foundation.base: 001_init → 002_add_users → 003_add_orgs
foundation.auth: 001_init → 002_add_browser_field
logistics.shipment: 001_init → 002_add_notes
All of these run together when you ede migrate upgrade -t acme.
Deleting / Resetting Migrations¶
Development only — to start fresh:
- Delete all files in
*/migrations/versions/*.py - Delete
.ede/migrations/ref.db - Drop the tenant DB (or delete the SQLite file)
- Run
ede migrate generate -m "initial"to regenerate - Run
ede migrate upgrade -t <tenant>to apply
Never do this in production.
Why Per-App Migration Chains?¶
Single migration history is a shared lock¶
In most frameworks, all migrations live in one directory with one linear history. Two developers adding tables to different apps on the same day creates a merge conflict in the migration chain. One of them has to renumber their migration and retest. As the team grows, this becomes a daily friction point.
EDE gives each app its own migration chain. Two developers working on logistics.shipment
and crm.lead simultaneously never touch each other's migration files.
Apps are deployable independently¶
Because each app has its own migration chain, you can add a new app to an existing system
without touching any existing migration. ede migrate upgrade -t acme will apply the new
app's migrations alongside the existing ones. Rollback with ede migrate downgrade --app
logistics.shipment -1 only affects that app's chain.
Table ownership is explicit¶
SqlAlchemyMetadataBuilder stamps each Table with the ede_app_key that owns it.
Alembic uses this to route new migration scripts to the correct versions/ directory.
There is no ambiguity about which app "owns" a table — it is declared in code, not inferred
from the filename.
Per-tenant migrations align with per-tenant databases¶
Because every tenant has its own database, migrations run per-tenant. This means: - A migration that introduces breaking changes can be applied to one tenant first (canary) - Failed migrations affect one tenant, not all - Tenants can be on different migration heads during a rolling upgrade
Best Practices¶
-
One migration per model change — small, focused migrations are easier to debug and rollback.
-
Always provide
downgrade()— even if it's just a comment. This documents the intended rollback path. -
Never modify an applied migration — once a migration has run in production, treat it as immutable. Create a new migration instead.
-
Test migrations before deploying — run
ede migrate upgrade -t <staging>on a staging tenant first. -
Commit
.ede/migrations/ref.dbalongside migrations — it is the shared autogenerate baseline. Whenede migrate generateupdates the binary, include it in the same commit as the generated migration script so other developers and CI diff against the same DB shape. -
Table names must match model — use
model_key.replace(".", "_")for table names in raw SQL migration ops. -
Multi-tenant awareness —
ede migrate upgraderuns per-tenant. If you have 10 tenants, you need to run it for each, or use--allif that flag is implemented.