Applies sequential SQL migrations to OptimCE databases. Each target database tracks its
schema with a schema_version table; the updater compares the current version against a
manifest, then applies every newer SQL file in order, in a transaction.
No Alembic. SQLAlchemy async + asyncpg, plain .sql files, hand-curated manifest.
optimce-migrator/
migrator.py
database.config # which databases to manage
migrations/
<database-name>/
migration.json # version -> sql file map (REQUIRED, even if empty)
001_init_schema_version.sql
002_...sql
One subfolder per key in database.config: optimce-crm, allocation-key,
simulation-key, news-board, billing, administrative-document.
JSON. Lists databases by name and points each at the env var that holds its async connection URL. Credentials never live in this file.
{
"databases": {
"optimce-crm": { "url_env": "OPTIMCE_CRM_DATABASE_URL", "ssl": false },
"allocation-key": { "url_env": "OPTIMCE_ALLOCATION_KEY_DATABASE_URL", "ssl": false },
"simulation-key": { "url_env": "OPTIMCE_SIMULATION_KEY_DATABASE_URL", "ssl": false },
"news-board": { "url_env": "OPTIMCE_NEWS_BOARD_DATABASE_URL", "ssl": false },
"billing": { "url_env": "OPTIMCE_BILLING_DATABASE_URL", "ssl": false },
"administrative-document": { "url_env": "OPTIMCE_ADMINISTRATIVE_DOCUMENT_DATABASE_URL", "ssl": false }
}
}Six databases, one per key. The key names the database, not the service that owns
it — optimce-crm is crm_db, allocation-key is allocation_key_local. Insertion
order is the run order (_run_all iterates the list in file order), and optimce-crm
comes first because every annexe reads the CRM.
| field | required | meaning |
|---|---|---|
| url_env | yes | name of the env var holding postgresql+asyncpg://user:pass@host:port/db |
| ssl | no | when true, connect with a default SSL context |
The key (optimce-crm) is also the name of the migrations subfolder.
Every entry needs its env var set, on every unscoped run.
load_database_configwalks the whole file and raises on the first variable it cannot resolve, so adding a database here breaks every caller that has not also been given its URL — before any database is touched. Add the entry and the deployment's URL in the same change, or scope the run with--database.In the compose deployment those six URLs live in one
environment:block inproduction/docker-compose/docker-compose.yml. That file and this image are updated by two different commands —git pullanddocker compose pull— and nothing enforces their order. Pull the repo first. Reversed, the run exits 1 with a message that names an environment variable and not the file it belongs in. The saving grace is that it fails at config-resolution time, before a single connection is opened, so the blast radius is a failed one-shot rather than a half-migrated database.
Which version a set starts at depends on who creates the database:
optimce-crm— the CRM base schema (crm-backend/database_script/init.sql) carries noschema_versiontable, so this set bootstraps it at version 1 and climbs from there.- annexe databases (
allocation-key,simulation-key,news-board,billing,administrative-document) — each service's ownscripts/sql/schema.sqlcreatesschema_versionand self-inserts a row for every version it already embodies. Those sets therefore start at version 2 and ship no bootstrap migration.
schema.sql is not "version 1 forever", and that is the part people get wrong.
allocation-key-generation/scripts/sql/schema.sql inserts rows 1, 2 and 3, so a
database created from it today is already at 3 and this migrator correctly skips both of
its files. They exist for the databases created before them.
| set | starts at | top version here | upstream source |
|---|---|---|---|
optimce-crm |
1 | 10 | crm-backend/database_script/*.sql |
allocation-key |
2 | 3 | allocation-key-generation/scripts/sql/migrations/ |
simulation-key |
2 | 2 | simulation-key/scripts/sql/migrations/ |
news-board |
— | — | news-board/scripts/sql/ — nothing pending yet |
billing |
2 | 2 | billing/scripts/sql/migrations/ |
administrative-document |
2 | 2 | administrative-document/scripts/sql/seeds/ |
A manifest description must be byte-identical to the string the upstream
schema.sql self-inserts for that version. A fresh install writes it from
schema.sql, a migrated install writes it from the manifest, and schema_version is
the only record either path leaves. If the two strings differ, "version 3" names two
different changes depending on how the database came to exist.
news-board is registered with an empty manifest — literally
{"migrations": []}. load_migrations returns an empty list, update_database logs
0 pending migration(s) and returns; the cost is one connection. It is registered ahead
of need so the deployment's environment block is already complete on the day news-board
gets its first migration — the alternative is a release that has to change
database.config here and the compose file there in the same breath, which is the
coupling that has already failed once. Note that the manifest file is what makes
this work: load_migrations raises FileNotFoundError on a missing migration.json,
so an empty directory is not enough.
A migration set is not a from-scratch schema. optimce-crm creates 9 of the CRM's 30
tables and assumes the rest exist: migration 002 references sharing_operation, 005
references community, 010 assumes address, and nothing here ever creates
update_changetimestamp_column(). The base schema stands a database up; the migrator
evolves it. Provision first, migrate second.
{
"migrations": [
{ "version": 1, "file": "001_init_schema_version.sql", "description": "Bootstrap schema_version table" }
]
}Versions are positive integers, must be unique, and are applied in ascending order.
- Create
migrations/<db-name>/NNN_what_it_does.sql. - Append a new entry to
migrations/<db-name>/migration.jsonwith the next version number, the file name, and a short description. - Commit both files together.
A file on disk that is not in the manifest does not exist. load_migrations reads
only migration.json; an unregistered .sql file is silently never applied, and nothing
warns you. Adding the file and forgetting the manifest entry is the easiest mistake to
make here and the hardest to notice.
Rules for the SQL itself:
- Do not open a transaction. No
BEGIN;/COMMIT;— the runner wraps each file in one transaction together with theINSERT INTO schema_version, so a failure rolls back both. ACOMMITinside the file ends that transaction early and re-splits them, which is exactly the "applied DDL, no version recorded" state the wrapper exists to prevent. - Do not insert your own
schema_versionrow. The runner writes it, using the version and description from the manifest. A file that inserts its own collides on the primary key and aborts. - Be idempotent.
CREATE TABLE/INDEX IF NOT EXISTS,ADD COLUMN IF NOT EXISTS,DROP TRIGGER IF EXISTSbeforeCREATE TRIGGER,ON CONFLICT DO NOTHINGon data. A migration can be replayed — against a database restored from a base schema that already contains some of its objects, for instance — and must survive it. - Adapting an upstream file? Strip three things. Annexe migrations are copied from
<service>/scripts/sql/migrations/, where they are written to be run by hand withpsql. Every one of them opensBEGIN;/COMMIT;, self-inserts itsschema_versionrow, and may not be replay-safe —ALTER TABLE ... RENAME COLUMNhas noIF EXISTS(seeallocation-key/002_file_storage_key.sqlfor theDO $$guard that fixes it). Remove the first two, fix the third, and keep the description byte-identical to the one the upstreamschema.sqlinserts.
For a database whose base schema has no schema_version table, the first migration
must create it: the runner treats "table not found" as version 0, so the bootstrap
applies itself cleanly on a fresh database. Annexe databases whose schema.sql already
self-inserts its versions skip this and start at version 2.
# install deps once
.venv/Scripts/python -m pip install -r requirements.txt
# point at the target databases. An UNSCOPED run needs one URL per entry in
# database.config — all six — or it exits 1 before touching anything. Working on
# one database? Use --database and export only its URL.
export OPTIMCE_CRM_DATABASE_URL=postgresql+asyncpg://crm_svc:pass@localhost:5432/crm_db
export OPTIMCE_ALLOCATION_KEY_DATABASE_URL=postgresql+asyncpg://allocation_key_svc:pass@localhost:5432/allocation_key_local
export OPTIMCE_SIMULATION_KEY_DATABASE_URL=postgresql+asyncpg://simulation_key_svc:pass@localhost:5432/simulation_key_local
export OPTIMCE_NEWS_BOARD_DATABASE_URL=postgresql+asyncpg://news_board_svc:pass@localhost:5432/news_board_local
export OPTIMCE_BILLING_DATABASE_URL=postgresql+asyncpg://billing_svc:pass@localhost:5432/billing_local
export OPTIMCE_ADMINISTRATIVE_DOCUMENT_DATABASE_URL=postgresql+asyncpg://administrative_document_svc:pass@localhost:5432/administrative_document_local
# preview pending migrations
python migrator.py --dry-run
# apply to every database in database.config
python migrator.py
# apply to one database only
python migrator.py --database optimce-crmFlags:
--database NAME— scope to a single database fromdatabase.config.--dry-run— list pending migrations, change nothing, do not require write access.--verbose— DEBUG-level logging.
- Each migration runs in its own transaction together with the
INSERT INTO schema_versionrow, so a partial apply cannot record a false success — and the reverse: applied DDL cannot go unrecorded. This holds only becauseapply_migrationforces the transaction open before executing the file (SQLAlchemy's asyncpg adapter starts it lazily, on the first statement issued through the adapter, and the migration itself runs on the raw connection) and because no migration file opens a transaction of its own. Both halves are load-bearing; see the comment inapply_migration. - If a migration fails, prior migrations stay applied; fix the SQL (or the data) and re-run — the migrator will resume from the last recorded version.
- Multi-database runs are sequential. One database's failure does not abort the others; the script exits non-zero if any database failed and lists which ones.
The Dockerfile bakes migrator.py, database.config, and the entire migrations/ tree
into the image. Each image release ships its target schema version — pulling and
running a new image brings every database in database.config up to that version.
docker build -t optimce-updater:dev .Run as a one-shot job inside the platform's docker network. The connection URL is
provided via env var (the same env var named in database.config):
docker run --rm \
--network optimce_default \
-e OPTIMCE_CRM_DATABASE_URL=postgresql+asyncpg://crm_user:pass@postgres:5432/crm_db \
optimce-updater:devIn docker-compose.yml:
services:
optimce-updater:
image: optimce-updater:${TAG:-latest}
restart: "no"
environment:
# One per entry in database.config — a missing one fails the whole run.
OPTIMCE_CRM_DATABASE_URL: postgresql+asyncpg://crm_svc:${CRM_DB_PASSWORD}@postgres:5432/crm_db
OPTIMCE_ALLOCATION_KEY_DATABASE_URL: postgresql+asyncpg://allocation_key_svc:${ALLOCATION_KEY_DB_PASSWORD}@postgres:5432/allocation_key_local
OPTIMCE_SIMULATION_KEY_DATABASE_URL: postgresql+asyncpg://simulation_key_svc:${SIMULATION_KEY_DB_PASSWORD}@postgres:5432/simulation_key_local
OPTIMCE_NEWS_BOARD_DATABASE_URL: postgresql+asyncpg://news_board_svc:${NEWS_BOARD_DB_PASSWORD}@postgres:5432/news_board_local
OPTIMCE_BILLING_DATABASE_URL: postgresql+asyncpg://billing_svc:${BILLING_DB_PASSWORD}@postgres:5432/billing_local
OPTIMCE_ADMINISTRATIVE_DOCUMENT_DATABASE_URL: postgresql+asyncpg://administrative_document_svc:${ADMINISTRATIVE_DOCUMENT_DB_PASSWORD}@postgres:5432/administrative_document_local
depends_on:
postgres:
condition: service_healthyThe image's ENTRYPOINT is python migrator.py, so any flags (--dry-run, --database,
--verbose) can be passed as arguments to docker run or command: in compose:
docker run --rm ... optimce-updater:dev --dry-run