This project uses TypeORM for schema management with a PostgreSQL database. Migrations are hand-written TypeScript files in src/migrations/.
| Command | Purpose |
|---|---|
npm run migration:generate |
Generate a migration from entity changes (not recommended — review generated output) |
npm run migration:run |
Apply pending migrations |
npm run migration:revert |
Revert the last applied migration |
All commands target the DataSource defined in src/config/db.ts (compiled to dist/config/db.js).
<TIMESTAMP>-<DescriptiveName>.ts
TIMESTAMPis a Unix-millisecond literal (e.g.1719619200001).DescriptiveNameis PascalCase matching the class name inside.- Example:
1719619200001-AddEmailVerificationAndPasswordResetToUser.ts
import { MigrationInterface, QueryRunner } from "typeorm";
export class DescriptiveName1719619200001 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// forward migration
}
public async down(queryRunner: QueryRunner): Promise<void> {
// rollback
}
}Every migration must implement both up and down.
Before a migration is merged, the following items must be verified:
- The
down()method exactly reversesup()(same columns, indexes, constraints). - Running
upthendownleaves the schema identical to the starting point. - If the migration adds a NOT NULL column,
downhandles the data that was backfilled.
- Adding a column: the column is nullable or has a safe default.
- Removing a column: no application code reads it anymore.
- Renaming a column: implemented as
ADD + COPY + DROPwith a data-migration step, never a singleRENAMEthat could break replicas. - Changing a column type: uses
USINGclause (e.g.ALTER COLUMN x TYPE bigint USING x::bigint). - Dropping a table: confirm no FK references remain and no queries target it.
- New query patterns have matching indexes (check
EXPLAIN ANALYZEon slow queries). - Indexes on columns used in
WHERE,JOIN,ORDER BY, orGROUP BY. - Composite indexes are ordered by selectivity (most selective column first).
- Existing unused indexes are candidates for removal (but remove in a separate migration).
- The migration has been run against a copy of production data to estimate wall-clock time.
- Large-table DDL (millions of rows) uses online DDL patterns (see below).
-
UPDATEstatements affecting many rows are batched.
Tables with >1M rows require extra care. Use these patterns:
For large tables, ALTER TABLE ... ADD COLUMN ... DEFAULT locks the table while PostgreSQL rewrites every row. Prefer a two-step approach:
- Add the column as nullable (instant metadata-only change).
- Backfill data in batches.
- Add the NOT NULL constraint and default separately.
// Step 1 — instant
await queryRunner.addColumn("song", new TableColumn({
name: "playCount",
type: "integer",
isNullable: true,
}));
// Step 2 — batched backfill
const batchSize = 1000;
let updated = 0;
do {
const result = await queryRunner.query(
`UPDATE song SET "playCount" = 0 WHERE "playCount" IS NULL LIMIT ${batchSize}`
);
updated = result[1] || 0;
} while (updated > 0);
// Step 3 — add constraint
await queryRunner.query(`ALTER TABLE song ALTER COLUMN "playCount" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE song ALTER COLUMN "playCount" SET DEFAULT 0`);await queryRunner.query(
`CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_song_playCount" ON "song" ("playCount")`
);Note: CREATE INDEX CONCURRENTLY must be executed outside a transaction. TypeORM migrations run inside a transaction by default. To opt out:
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.commitTransaction();
await queryRunner.query(`CREATE INDEX CONCURRENTLY ...`);
await queryRunner.startTransaction();
}Alternatively, use TypeORM's disableForeignKeys or run raw SQL.
await queryRunner.query(`DROP INDEX CONCURRENTLY IF EXISTS "IDX_song_obsolete"`);- Deploy code that stops writing to/reading from the old column.
- In a later migration, drop the column.
await queryRunner.dropColumn("song", "obsoleteColumn");npm run migration:revertThis reverts the single most recently applied migration. Repeat to revert multiple.
If a migration causes production issues:
- Immediate: Run
npm run migration:revertto undo the last migration. - Verify: Confirm the schema matches the previous state (
SELECT * FROM migrations ORDER BY id DESC LIMIT 5). - Communicate: Notify the team via the incident channel.
- Follow-up: Create a fix migration (do not edit the reverted migration — it's already in git history).
If a migration was merged without a down() method:
- Manually craft the reversing SQL.
- Verify on a staging DB first.
- Apply via
queryRunner.query(...)in a new migration, or execute directly in a maintenance window.
- Docker and docker-compose installed.
- A local
.env.testfile with test database credentials (see.env.example).
scripts/test-migration.shThis script:
- Starts a fresh PostgreSQL container.
- Runs all existing migrations via TypeORM.
- Runs
npm run migration:revertto verifydown(). - Re-applies migrations to verify re-up works.
- Drops the test database.
For every migration:
- Run
npm run buildto compile TypeScript. - Start a clean test DB:
docker compose up -d db_test(or use the script). - Run
npm run migration:run— verify no errors. - Inspect the schema:
\dtand\d <table>inpsql. - Insert a representative row and verify constraints.
- Run
npm run migration:revert— verify schema returns to the previous state. - Re-run
npm run migration:runto confirm it applies cleanly again. - Run the full Jest suite:
npm test. - If the migration changes entities, verify the app boots and responds to health check.
After applying the migration to staging:
-- Count rows before/after to verify no accidental truncation
SELECT COUNT(*) FROM <affected_table>;
-- Verify NOT NULL columns
SELECT COUNT(*) FROM <affected_table> WHERE <new_column> IS NULL;
-- Check FK integrity
SELECT COUNT(*) FROM <child_table> c
LEFT JOIN <parent_table> p ON c.<fk> = p.id
WHERE p.id IS NULL;[Develop] → [Code Review] → [Test DB] → [Staging] → [Production]
↑ ↓ ↓ ↓ ↓
Write up/down Checklist Run script Run migration Apply in
+ test data + verify maintenance
window
- Migrations are written on feature branches.
- They are reviewed as part of the PR (see checklist above).
- After merge to
main, the migration is applied to staging. - After staging verification, it is queued for the next production deployment.
| Tool | Purpose |
|---|---|
typeorm migration:create |
Scaffold an empty migration file |
typeorm migration:generate |
Auto-generate from entity diff (review output carefully) |
typeorm migration:run |
Apply pending migrations |
typeorm migration:revert |
Undo the last migration |
typeorm migration:show |
List all migrations and their status |
psql |
Inspect schema and run manual queries |
scripts/test-migration.sh |
Automated CI-style migration test |