Skip to content

TypeORM Migration Guide

backupctl uses schema-driven TypeORM migrations for all audit database schema changes. The workflow is: modify the *.record.ts schema first, then use migration:generate to produce the migration from the entity diff. Migrations are never auto-run — you have full manual control.

Golden rule: The record schema (*.record.ts) is the source of truth. Always change the schema first, then generate the migration. Never hand-write migrations for schema changes.


How It Works

  • Entity: src/domain/audit/infrastructure/persistence/typeorm/schema/backup-log.record.ts
  • Config: src/config/typeorm.config.ts (env-aware, __dirname-relative paths)
  • CLI Data Source: src/db/datasource.ts
  • Migrations dir: src/db/migrations/
  • Auto-run: migrationsRun: false — you must run migrations manually

TypeORM tracks applied migrations in the migrations table in PostgreSQL. Run migrate:show to see pending vs applied migrations.


Config Architecture

The TypeORM config is extracted into src/config/typeorm.config.ts with environment-specific settings:

  • Development: uses *.{js,ts} globs, enables migration + warn + error logging
  • Production: uses *.js only, enables error logging only
  • Both environments: migrationsRun: false, synchronize: false

The config is registered via @nestjs/config's registerAs('typeorm', ...) and consumed by AppModule through ConfigService.

A standalone src/db/datasource.ts exposes the same config as a raw DataSource for the TypeORM CLI.


Migration Commands

The easiest way to run migration commands is through scripts/dev.sh. All commands below assume the dev environment is running (scripts/dev.sh up).

Run Pending Migrations

bash
scripts/dev.sh migrate:run

This is required after pulling new migration files or creating new ones.

Show Migration Status

bash
scripts/dev.sh migrate:show

Shows all migrations and whether they have been applied ([X]) or are pending.

Generate a Migration (from Entity Changes)

After modifying the BackupLogRecord entity, generate a migration that captures the diff:

bash
scripts/dev.sh migrate:generate DescriptiveName

This compares the current entity definitions against the database schema and generates a migration file with the necessary ALTER TABLE statements.

Important: The database must be running and up to date with previous migrations for generate to produce a correct diff.

Create an Empty Migration

For manual schema changes (indexes, constraints, data migrations):

bash
scripts/dev.sh migrate:create DescriptiveName

This creates a blank migration file with up() and down() methods for you to fill in.

Revert Last Migration

bash
scripts/dev.sh migrate:revert

This reverts the most recently applied migration by calling its down() method.

Direct TypeORM CLI (without dev.sh)

If you need to run TypeORM commands directly, use ts-node with tsconfig-paths/register to resolve path aliases:

bash
# Inside dev container
docker exec backupctl-dev npx ts-node -r tsconfig-paths/register \
  ./node_modules/typeorm/cli.js migration:show \
  -d src/db/datasource.ts

# Local (with Postgres accessible on localhost)
npx ts-node -r tsconfig-paths/register \
  ./node_modules/typeorm/cli.js migration:run \
  -d src/db/datasource.ts

Writing a Migration

Naming Convention

Migration files are named with a timestamp prefix and a descriptive PascalCase name:

{timestamp}-DescriptiveName.ts

Examples:

  • 1710720000000-CreateBackupLogTable.ts
  • 1710820000000-AddTagsColumnToBackupLog.ts
  • 1710920000000-CreateIndexOnProjectName.ts

Migration Template

typescript
import { MigrationInterface, QueryRunner, TableColumn, TableIndex } from 'typeorm';

export class AddTagsColumnToBackupLog1710820000000 implements MigrationInterface {
  name = 'AddTagsColumnToBackupLog1710820000000';

  async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.addColumn(
      'backup_log',
      new TableColumn({
        name: 'tags',
        type: 'jsonb',
        isNullable: true,
      }),
    );

    await queryRunner.createIndex(
      'backup_log',
      new TableIndex({
        name: 'IDX_backup_log_tags',
        columnNames: ['tags'],
      }),
    );
  }

  async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.dropIndex('backup_log', 'IDX_backup_log_tags');
    await queryRunner.dropColumn('backup_log', 'tags');
  }
}

Rules

  1. Schema first — always modify the *.record.ts file, then run migrate:generate. Never hand-write schema migrations
  2. Use migrate:create only for data migrations, custom indexes, or SQL that generate can't capture
  3. Always implement down() — reversibility is required, even if you think you'll never revert
  4. Never modify an existing migration that has been applied in any environment — create a new one instead
  5. Keep migrations small and focused — one logical change per migration
  6. Review the generated migration before running it — generate can sometimes produce unnecessary changes
  7. Update the mapper (backup-log.mapper.ts) if the new columns need domain-level representation
  8. Test migrations by running them against a fresh database:
    bash
    docker compose -f docker-compose.dev.yml down -v
    docker compose -f docker-compose.dev.yml up -d
    scripts/dev.sh migrate:run

Common Patterns

Add a Column

typescript
async up(queryRunner: QueryRunner): Promise<void> {
  await queryRunner.addColumn('backup_log', new TableColumn({
    name: 'duration_seconds',
    type: 'int',
    isNullable: true,
  }));
}

async down(queryRunner: QueryRunner): Promise<void> {
  await queryRunner.dropColumn('backup_log', 'duration_seconds');
}

Add an Index

typescript
async up(queryRunner: QueryRunner): Promise<void> {
  await queryRunner.createIndex('backup_log', new TableIndex({
    name: 'IDX_backup_log_project_status',
    columnNames: ['project_name', 'status'],
  }));
}

async down(queryRunner: QueryRunner): Promise<void> {
  await queryRunner.dropIndex('backup_log', 'IDX_backup_log_project_status');
}

Rename a Column

typescript
async up(queryRunner: QueryRunner): Promise<void> {
  await queryRunner.renameColumn('backup_log', 'old_name', 'new_name');
}

async down(queryRunner: QueryRunner): Promise<void> {
  await queryRunner.renameColumn('backup_log', 'new_name', 'old_name');
}

Change Column Type

typescript
async up(queryRunner: QueryRunner): Promise<void> {
  await queryRunner.changeColumn('backup_log', 'duration_ms', new TableColumn({
    name: 'duration_ms',
    type: 'numeric',
    isNullable: true,
  }));
}

async down(queryRunner: QueryRunner): Promise<void> {
  await queryRunner.changeColumn('backup_log', 'duration_ms', new TableColumn({
    name: 'duration_ms',
    type: 'bigint',
    isNullable: true,
  }));
}

Data Migration

typescript
async up(queryRunner: QueryRunner): Promise<void> {
  await queryRunner.addColumn('backup_log', new TableColumn({
    name: 'is_encrypted',
    type: 'boolean',
    default: false,
  }));

  // Backfill from existing data
  await queryRunner.query(`
    UPDATE backup_log SET is_encrypted = true WHERE encrypted = true
  `);
}

Workflow Summary

1. Modify record schema   →  *.record.ts (source of truth)
2. Generate migration     →  scripts/dev.sh migrate:generate DescriptiveName
3. Review generated file  →  src/db/migrations/
4. Run migration          →  scripts/dev.sh migrate:run
5. Verify                 →  scripts/dev.sh migrate:show
6. Update mapper          →  *.mapper.ts (if new columns need domain mapping)
7. Commit record + migration + mapper together

Troubleshooting

"No changes in database schema were found"

The database is already in sync with the entity. Either:

  • You forgot to save the entity file
  • The migration was already generated and applied
  • The database is out of sync — run pending migrations first

"Migration has already been applied"

TypeORM tracks applied migrations in the migrations table. If you need to re-run a migration:

sql
-- Check applied migrations
SELECT * FROM migrations ORDER BY timestamp DESC;

-- Remove a migration record (use with caution)
DELETE FROM migrations WHERE name = 'MigrationName1710820000000';

Then re-run migrate:run.

"Cannot find data source" or "Cannot find module"

Make sure you're using ts-node with tsconfig-paths/register to resolve path aliases. The scripts/dev.sh commands handle this automatically. If running manually:

bash
npx ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:show \
  -d src/db/datasource.ts

Also verify the database is running and accessible with the credentials in .env.

Fresh Start (Development Only)

bash
scripts/dev.sh reset
scripts/dev.sh migrate:run

All migrations will re-run from scratch on the fresh database.

Released under the MIT License.