Опирается на правила: R-TYPEORM-MIG-1, R-TYPEORM-MIG-2, R-TYPEORM-MIG-X1, R-TYPEORM-MIG-X2 из TypeORM Style Guide → раздел 6. Миграции. Безопасность DDL-операций — по правилам PG-M-* из PostgreSQL: миграции.

Важно знать

  • Схема — только через миграции. synchronize: true в продакшене запрещён — он молча дропает и пересоздаёт столбцы при несовпадении типов.
  • migration:generate — отправная точка, не финальный результат: переименования видны как drop+create, часть типов и индексов генератор не распознаёт правильно.
  • Применённую миграцию не редактируют. Исправление — новой миграцией поверх.
  • migration:run запускается отдельной командой — до поднятия HTTP-сервера, явно в жизненном цикле.
  • CREATE INDEX CONCURRENTLY требует отдельной транзакции (флаг transaction: 'none' на классе миграции) — иначе TypeORM обернёт в транзакцию, и Postgres откажет.
  • Expand-contract применяется для переименований и удалений: добавить новый столбец → переключить код → убрать старый, три отдельных деплоя.
  • Безопасные значения lock_timeout (≤ 3s) и statement_timeout для длинных UPDATE — по PG-M-*.

Схема PostgreSQL версионируется файлами миграций. TypeORM хранит состояние в таблице typeorm_migrations: при каждом запуске migration:run он сверяет список применённых записей с файлами на диске и выполняет только отсутствующие. Это и есть единый источник правды — ни synchronize, ни ручные CREATE TABLE в коде приложения.

Структура файлов и DataSource для CLI

TypeORM CLI нужен отдельный DataSource — без модулей NestJS, без DI-контейнера. Конфигурация выносится в src/database/data-source.ts и используется как точкой CLI, так и модулем приложения.

// src/database/data-source.ts
import { DataSource } from 'typeorm';
import * as dotenv from 'dotenv';

dotenv.config();

export const AppDataSource = new DataSource({
  type: 'postgres',
  url: process.env.DATABASE_URL,
  entities: ['src/**/*.entity.ts'],
  migrations: ['src/database/migrations/*.ts'],
  synchronize: false,
  logging: false,
});

Миграции живут в src/database/migrations/. Каждый файл — нумерованный timestamp, который TypeORM проставляет автоматически:

src/database/migrations/
├── 1700000000000-CreateOrders.ts
├── 1700000001000-CreateProducts.ts
└── 1700000002000-AddCustomerIndex.ts

В package.json — три команды, которые вызываются через typeorm-ts-node-commonjs (или ts-node):

{
  "scripts": {
    "migration:generate": "typeorm-ts-node-commonjs migration:generate -d src/database/data-source.ts",
    "migration:run": "typeorm-ts-node-commonjs migration:run -d src/database/data-source.ts",
    "migration:revert": "typeorm-ts-node-commonjs migration:revert -d src/database/data-source.ts"
  }
}

migration:generate — генерация и обязательная вычитка

R-TYPEORM-MIG-1: генератор сравнивает Entity-декораторы с текущей схемой БД и создаёт миграцию. Запуск:

npm run migration:generate -- src/database/migrations/AddPaymentMethodToOrders

TypeORM создаёт файл с up() и down(). Пример для добавления столбца:

// src/database/migrations/1700000003000-AddPaymentMethodToOrders.ts
import { MigrationInterface, QueryRunner } from 'typeorm';

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

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `ALTER TABLE "orders" ADD "payment_method" character varying`,
    );
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `ALTER TABLE "orders" DROP COLUMN "payment_method"`,
    );
  }
}

Сгенерированный файл нужно прочитать до коммита. Генератор ошибается в трёх сценариях:

Переименование столбца — видит как drop + create с потерей данных:

// Генератор выдаст — НЕВЕРНО при переименовании:
await queryRunner.query(`ALTER TABLE "orders" DROP COLUMN "customer_name"`);
await queryRunner.query(`ALTER TABLE "orders" ADD "client_name" character varying`);

// Нужно заменить на:
await queryRunner.query(
  `ALTER TABLE "orders" RENAME COLUMN "customer_name" TO "client_name"`,
);

Индексы — генератор создаёт обычный CREATE INDEX, без CONCURRENTLY. На таблицах с трафиком это берёт ShareLock и блокирует записи. Такие строки нужно заменять вручную (см. раздел «Индексы без блокировки»).

Enum-изменения — добавление значения в enum требует отдельного ALTER TYPE ... ADD VALUE, которое нельзя выполнить внутри транзакции. Генератор такого не знает.

migration:run при старте сервиса

R-TYPEORM-MIG-1 (cross-ref NESTBOOT-9): миграции применяются до поднятия HTTP-сервера. В NestJS это делается через хук onApplicationBootstrap или через явный вызов в точке входа:

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { AppDataSource } from './database/data-source';

async function bootstrap(): Promise<void> {
  const dataSource = await AppDataSource.initialize();
  await dataSource.runMigrations();
  await dataSource.destroy();

  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}

bootstrap();

Или через отдельный MigrationModule, если DataSource инициализируется через TypeOrmModule.forRootAsync:

// src/database/migrations.runner.ts
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { DataSource } from 'typeorm';

@Injectable()
export class MigrationsRunner implements OnApplicationBootstrap {
  constructor(private readonly dataSource: DataSource) {}

  async onApplicationBootstrap(): Promise<void> {
    await this.dataSource.runMigrations();
  }
}

runMigrations() идемпотентен: если миграции уже применены — ничего не делает. Если нет — применяет отсутствующие по порядку timestamp.

Expand-contract для обратно-несовместимых изменений

R-TYPEORM-MIG-2 отсылает к PG-M-*: при rolling-деплое удалить или переименовать столбец за один шаг нельзя — старые pod'ы ещё работают с прежней схемой.

Паттерн для переименования statusorder_status в orders:

Миграция 1 — expand: добавить новый столбец, заполнить данными, создать триггер синхронизации:

export class ExpandOrderStatus1700000010000 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `ALTER TABLE "orders" ADD "order_status" character varying`,
    );
    await queryRunner.query(
      `UPDATE "orders" SET "order_status" = "status"`,
    );
    await queryRunner.query(`
      CREATE OR REPLACE FUNCTION sync_order_status()
      RETURNS TRIGGER AS $$
      BEGIN
        NEW.order_status := NEW.status;
        RETURN NEW;
      END;
      $$ LANGUAGE plpgsql
    `);
    await queryRunner.query(`
      CREATE TRIGGER trg_sync_order_status
      BEFORE INSERT OR UPDATE ON orders
      FOR EACH ROW EXECUTE FUNCTION sync_order_status()
    `);
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `DROP TRIGGER IF EXISTS trg_sync_order_status ON orders`,
    );
    await queryRunner.query(`DROP FUNCTION IF EXISTS sync_order_status()`);
    await queryRunner.query(
      `ALTER TABLE "orders" DROP COLUMN "order_status"`,
    );
  }
}

Шаг кода: обновить Entity и репозиторий на order_status, задеплоить. Триггер обеспечивает согласованность пока оба поля существуют.

Миграция 2 — contract: убрать старый столбец и триггер:

export class ContractDropStatus1700000020000 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `DROP TRIGGER IF EXISTS trg_sync_order_status ON orders`,
    );
    await queryRunner.query(`DROP FUNCTION IF EXISTS sync_order_status()`);
    await queryRunner.query(
      `ALTER TABLE "orders" DROP COLUMN "status"`,
    );
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `ALTER TABLE "orders" ADD "status" character varying`,
    );
    await queryRunner.query(`UPDATE "orders" SET "status" = "order_status"`);
  }
}

Индексы без блокировки

CREATE INDEX без CONCURRENTLY берёт ShareLock на таблицу на время построения — при трафике это останавливает записи на секунды или минуты в зависимости от объёма.

R-TYPEORM-MIG-2, PG-M-*: индексы на таблицах с трафиком создаются через CONCURRENTLY. Но CONCURRENTLY не работает внутри транзакции — TypeORM по умолчанию оборачивает каждую миграцию в транзакцию.

Решение — флаг transaction: 'none' на классе:

// src/database/migrations/1700000005000-AddCustomerIdIndex.ts
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddCustomerIdIndex1700000005000 implements MigrationInterface {
  name = 'AddCustomerIdIndex1700000005000';
  transaction = false;

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`SET lock_timeout = '2s'`);
    await queryRunner.query(`
      CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_customer_id
      ON orders (customer_id)
    `);
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      DROP INDEX CONCURRENTLY IF EXISTS idx_orders_customer_id
    `);
  }
}

SET lock_timeout = '2s' ограничивает время ожидания захвата метаданных: если за 2 секунды не удалось — миграция падает с ошибкой, а не зависает и не блокирует трафик.

CREATE INDEX CONCURRENTLY IF NOT EXISTS — идемпотентен: при повторном запуске (например, после ошибки с invalid индексом) не упадёт.

Изоляция в тестах

Интеграционные тесты репозиториев запускают миграции на чистой БД в Testcontainers:

// test/setup/database.ts
import { GenericContainer, StartedTestContainer } from 'testcontainers';
import { DataSource } from 'typeorm';
import { AppDataSource } from '../../src/database/data-source';

let container: StartedTestContainer;
let dataSource: DataSource;

export async function setupTestDatabase(): Promise<DataSource> {
  container = await new GenericContainer('postgres:16-alpine')
    .withEnvironment({ POSTGRES_DB: 'testdb', POSTGRES_USER: 'test', POSTGRES_PASSWORD: 'test' })
    .withExposedPorts(5432)
    .start();

  const port = container.getMappedPort(5432);

  dataSource = new DataSource({
    ...AppDataSource.options,
    host: 'localhost',
    port,
    database: 'testdb',
    username: 'test',
    password: 'test',
  });

  await dataSource.initialize();
  await dataSource.runMigrations();
  return dataSource;
}

export async function teardownTestDatabase(): Promise<void> {
  await dataSource.destroy();
  await container.stop();
}

Миграции применяются один раз в beforeAll, контейнер переиспользуется всеми тестами. Изоляция данных — через транзакцию, откатываемую в afterEach:

describe('TypeOrmOrderRepository', () => {
  let dataSource: DataSource;
  let queryRunner: QueryRunner;

  beforeAll(async () => {
    dataSource = await setupTestDatabase();
  });

  afterAll(async () => {
    await teardownTestDatabase();
  });

  beforeEach(async () => {
    queryRunner = dataSource.createQueryRunner();
    await queryRunner.startTransaction();
  });

  afterEach(async () => {
    await queryRunner.rollbackTransaction();
    await queryRunner.release();
  });

  it('save и findById — round-trip агрегата Order', async () => {
    const em = queryRunner.manager;
    const repo = new TypeOrmOrderRepository(em);

    const order = Order.create({
      customerId: 'customer-uuid-1',
      productId: 'product-uuid-1',
      amount: new Big('1500.00'),
    });

    await repo.save(order);
    const found = await repo.findById(order.id);

    expect(found?.id).toBe(order.id);
    expect(found?.amount.toFixed(2)).toBe('1500.00');
  });
});

Репозиторий в тесте получает em из queryRunner.manager — это тот же транзакционный контекст, что и при работе через Handler.

Что запрещено

АнтипаттернПравилоЧто взамен
synchronize: true в продакшенеR-TYPEORM-MIG-X1synchronize: false, только миграции
Редактирование применённой миграцииR-TYPEORM-MIG-X2Новая миграция поверх
CREATE INDEX без CONCURRENTLY на таблице с трафикомPG-M-*transaction = false + CREATE INDEX CONCURRENTLY IF NOT EXISTS + lock_timeout
Удаление столбца за один деплойPG-M-*Expand-contract: добавить новый → переключить код → убрать старый
queryRunner.query с интерполяцией данных (\... ${value}`) |R-TYPEORM-QRY-X4| Параметризованные запросы черезquery(sql, [params])`
migration:run после поднятия HTTP (под трафиком)R-TYPEORM-MIG-1runMigrations() до app.listen()

Куда дальше

  • node/repository-pattern.md — как Entity и миграции связаны с репозиторием и доменными объектами
  • node/transactions.md — dataSource.transaction() и CLS-контекст: граница транзакции на Handler
  • PostgreSQL: миграции без даунтайма — правила PG-M-*: expand-contract, CONCURRENTLY, lock_timeout, длинные UPDATE