Skip to main content

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 the typeorm_migrations table (db/data-source.ts).
  • migration:generate / migration:run / migration:revert all run against ./dist/db/data-source.jsbuild 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:

  1. 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.)
  2. 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):

  1. Backfill the column's data into attributeValues (or wherever it now lives) in the same up().
  2. Drop the column and any enum type it used.
  3. Write the full inverse in down() (recreate type, re-add column, re-derive data — as above).
  4. 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.