Migrations & Schema
TypeORM, Postgres, migration-driven. synchronize is off everywhere —
the schema only ever changes through a migration file.
The workflow
# 1. change an entity (*.entity.ts)
npm run build # migrations read from dist/, not source
npm run migration:generate db/migrations/DescribeTheChange
# 2. review the generated file — TypeORM's diff is not always what you want
npm run build
npm run migration:run
- Never hand-write a migration from scratch — generate it from the entity change, then edit. Data migrations (backfills) are the exception: those are written by hand inside a generated shell.
- Files live in
db/migrations/, named<timestamp>-<Name>.ts. The timestamp orders execution. Applied migrations are tracked in thetypeorm_migrationstable (db/data-source.ts). migration:generate/migration:run/migration:revertall run against./dist/db/data-source.js— build first or they see nothing.
Every migration is reversible
up() and down() are both required, and down() has to actually work —
it's not decoration. For a data-carrying change that means down()
restores the column and re-derives its data:
// BackfillAndDropWastageType — down()
await queryRunner.query(`CREATE TYPE "..._wastagetype_enum" AS ENUM(...)`);
await queryRunner.query(`ALTER TABLE stock_movement ADD "WastageType" ... DEFAULT 'NA'`);
await queryRunner.query(`
UPDATE stock_movement
SET "WastageType" = ("attributeValues"->>'wastage_reason')::"..._wastagetype_enum"
WHERE "attributeValues" ? 'wastage_reason'
`);
The live-Rwanda constraint
A production Rwanda instance runs on this same migration history. Two hard rules follow:
- Never edit or delete a migration that has shipped. Rwanda has already recorded it as applied; changing the file makes a fresh deployment's schema diverge from Rwanda's. If a shipped migration is wrong, add a new corrective migration — don't rewrite history. (This has been caught in review before: a branch that added a second redundant delete-migration without checking that an earlier one already did the work.)
- DHIS2-mode behaviour must stay observably identical. A migration can change storage freely; it must not change what a Rwanda user submits or gets back.
Dropping columns
The original rule was "no destructive migrations until after cutover."
That has since relaxed to a disciplined pattern, now that the
attribute engine is a proven replacement path.
The current shape (BackfillAndDrop*, DropDead* migrations):
- Backfill the column's data into
attributeValues(or wherever it now lives) in the sameup(). - Drop the column and any enum type it used.
- Write the full inverse in
down()(recreate type, re-add column, re-derive data — as above). - Only do this once the replacement path is live in production, not speculatively.
Columns dropped this way so far include StockMovement.WastageType,
StockMovement.VVMStage / funder / partnerName / shipmentValueInRWF,
Batch.DozesPerVialOrUnitPerbox, User.claims, and the dead
OrderDetails VVM columns. If you're about to drop something not in that
family, raise it before writing the migration.
Seed migrations
Some migrations carry no schema change, only data: the seeded
roles and permissions, the seeded
attribute definitions,
seeded report profiles, seeded configuration rows
(features.*, epi.enabled, auto_return_shortfall, …). These follow the
same rules — reversible, never edited after shipping. Data-conditional ones
check ADAPTER_MODE before deciding what to insert.