Опирается на правила:
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'ы ещё работают с прежней схемой.
Паттерн для переименования status → order_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-X1 | synchronize: 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-1 | runMigrations() до app.listen() |
Куда дальше
- node/repository-pattern.md — как Entity и миграции связаны с репозиторием и доменными объектами
- node/transactions.md —
dataSource.transaction()и CLS-контекст: граница транзакции на Handler - PostgreSQL: миграции без даунтайма — правила
PG-M-*: expand-contract,CONCURRENTLY,lock_timeout, длинныеUPDATE