PostgreSQL из Node.js

PostgreSQL можно использовать в Node.js напрямую через драйвер pg или через ORM: Prisma, TypeORM, Sequelize и другие библиотеки.

Драйвер предоставляет прямой доступ к SQL, а ORM добавляет модели, связи, миграции и API более высокого уровня.

Независимо от выбранного подхода приложение должно:

Пример переменной окружения:

DATABASE_URL=postgresql://app_user:secret@localhost:5432/app_db

Файл .env с настоящими паролями не следует добавлять в Git:

.env
.env.*
!.env.example

Безопасный шаблон .env.example:

DATABASE_URL=postgresql://USER:PASSWORD@HOST:5432/DATABASE

Драйвер pg и ORM

Драйвер pg

pg, также известный как node-postgres, — низкоуровневый драйвер PostgreSQL для Node.js. Приложение самостоятельно формирует SQL-запросы и обрабатывает результаты.

Установка:

npm install pg

Пример:

import pg from 'pg';

const { Pool } = pg;

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

const result = await pool.query(
  `SELECT id, email, created_at
   FROM users
   WHERE id = $1`,
  [42],
);

console.log(result.rows[0]);

Преимущества pg:

Недостатки pg:

ORM

ORM — Object-Relational Mapping — слой, который сопоставляет таблицы базы данных с моделями или объектами приложения.

Условный ORM-запрос:

const user = await orm.user.findUnique({
  where: { id: 42 },
  include: { posts: true },
});

ORM обычно предоставляет:

Преимущества ORM:

Недостатки ORM:

Сравнение подходов

Ситуация Рекомендуемый подход
Нужен полный контроль над SQL pg
Много сложных и аналитических запросов pg или raw SQL в ORM
Типовое CRUD-приложение ORM
Важна строгая типизация Prisma
Проект построен на классах и декораторах TypeORM
Нужна классическая ORM с моделями Sequelize
Небольшой сервис с несколькими запросами pg
Команда хорошо знает SQL pg или гибридный подход

Драйвер и ORM можно сочетать. Например, Prisma может использоваться для стандартных CRUD-операций, а сложный отчёт — выполняться через параметризованный raw SQL.


Подключение к PostgreSQL

Строка подключения обычно имеет формат:

postgresql://USER:PASSWORD@HOST:PORT/DATABASE

Пример:

postgresql://app_user:secret@localhost:5432/app_db

Если имя пользователя, пароль или имя базы содержат специальные символы, их необходимо URL-кодировать.

Одиночное соединение через Client

Client представляет одно физическое соединение:

import pg from 'pg';

const { Client } = pg;

const client = new Client({
  connectionString: process.env.DATABASE_URL,
});

await client.connect();

try {
  const result = await client.query(
    'SELECT NOW() AS current_time',
  );

  console.log(result.rows[0]);
} finally {
  await client.end();
}

Одиночный клиент подходит для:

Для серверного приложения обычно используется пул.


Пул соединений

Создание нового соединения требует времени и ресурсов. Пул соединений поддерживает ограниченное количество открытых соединений и повторно использует их для разных запросов.

Создание пула

import pg from 'pg';

const { Pool } = pg;

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 5_000,
});

pool.on('error', (error) => {
  console.error(
    'Неожиданная ошибка свободного соединения PostgreSQL',
    error,
  );
});

Основные параметры:

Параметр Назначение
max Максимальное число клиентов в пуле
idleTimeoutMillis Время до закрытия неиспользуемого клиента
connectionTimeoutMillis Максимальное ожидание соединения
allowExitOnIdle Разрешает процессу завершиться при свободных соединениях

Пул создают один раз на процесс, а не для каждого HTTP-запроса.

Размер пула нужно рассчитывать для всей инфраструктуры. Если запущено 20 экземпляров приложения с max: 10, суммарно они могут открыть до 200 соединений.

Следует учитывать:

Выполнение одиночного запроса

Для независимого запроса удобно использовать pool.query():

const result = await pool.query(
  `SELECT id, title, published_at
   FROM posts
   WHERE author_id = $1
   ORDER BY published_at DESC
   LIMIT $2`,
  [authorId, 20],
);

Пул автоматически:

  1. получает свободного клиента;
  2. выполняет запрос;
  3. возвращает клиента в пул.

Получение клиента вручную

Для транзакции или последовательности операций, привязанных к одной сессии, клиент получают вручную:

const client = await pool.connect();

try {
  const result = await client.query(
    'SELECT current_database()',
  );

  console.log(result.rows[0]);
} finally {
  client.release();
}

client.release() обязательно вызывают в finally.

Если не возвращать клиенты, пул постепенно исчерпается, после чего новые запросы будут ожидать свободное соединение.

Проверка подключения

export async function checkDatabaseConnection() {
  const result = await pool.query(
    `SELECT
       current_database() AS database,
       NOW() AS connected_at`,
  );

  return result.rows[0];
}

Если приложение не может работать без базы, такую проверку можно выполнить перед запуском HTTP-сервера.

Завершение пула

async function shutdown(signal) {
  console.log(`Получен сигнал ${signal}`);

  try {
    await pool.end();
    process.exit(0);
  } catch (error) {
    console.error('Не удалось закрыть пул', error);
    process.exit(1);
  }
}

process.once('SIGINT', () => shutdown('SIGINT'));
process.once('SIGTERM', () => shutdown('SIGTERM'));

В полноценном сервере сначала прекращают принимать новые запросы, затем завершают активные операции и только после этого закрывают пул.


Параметризованные запросы

Пользовательские значения нельзя вставлять в SQL с помощью конкатенации или шаблонных строк.

Небезопасный вариант:

const result = await pool.query(
  `SELECT * FROM users WHERE email = '${email}'`,
);

Безопасный вариант:

const result = await pool.query(
  `SELECT id, email
   FROM users
   WHERE email = $1`,
  [email],
);

Параметры PostgreSQL обозначаются как $1, $2, $3:

const result = await pool.query(
  `SELECT id, email
   FROM users
   WHERE status = $1
     AND created_at >= $2
   ORDER BY created_at DESC
   LIMIT $3`,
  ['active', fromDate, 50],
);

Параметрами можно передавать значения, но обычно нельзя передавать:

Динамические идентификаторы следует выбирать из белого списка:

const allowedSortFields = new Set([
  'created_at',
  'email',
  'name',
]);

const sortField = allowedSortFields.has(requestedSort)
  ? requestedSort
  : 'created_at';

const direction =
  requestedDirection === 'asc' ? 'ASC' : 'DESC';

const result = await pool.query(
  `SELECT id, email, name
   FROM users
   ORDER BY ${sortField} ${direction}
   LIMIT $1`,
  [20],
);

Этот пример безопасен только потому, что sortField и direction формируются из заранее определённого набора значений.


CRUD через pg

Создание записи

export async function createUser({ email, name }) {
  const result = await pool.query(
    `INSERT INTO users (email, name)
     VALUES ($1, $2)
     RETURNING
       id,
       email,
       name,
       created_at AS "createdAt"`,
    [email, name],
  );

  return result.rows[0];
}

Чтение записи

export async function findUserById(id) {
  const result = await pool.query(
    `SELECT
       id,
       email,
       name,
       created_at AS "createdAt"
     FROM users
     WHERE id = $1`,
    [id],
  );

  return result.rows[0] ?? null;
}

Обновление записи

export async function updateUserName(id, name) {
  const result = await pool.query(
    `UPDATE users
     SET
       name = $2,
       updated_at = NOW()
     WHERE id = $1
     RETURNING
       id,
       email,
       name,
       updated_at AS "updatedAt"`,
    [id, name],
  );

  return result.rows[0] ?? null;
}

Удаление записи

export async function deleteUser(id) {
  const result = await pool.query(
    `DELETE FROM users
     WHERE id = $1
     RETURNING id`,
    [id],
  );

  return result.rowCount > 0;
}

Конструкция RETURNING позволяет получить созданную, изменённую или удалённую строку без дополнительного SELECT.


ORM Prisma

Prisma использует декларативную схему и генерирует клиент для работы с базой.

Установка

npm install @prisma/client
npm install --save-dev prisma
npx prisma init

Описание моделей

Файл prisma/schema.prisma:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  posts     Post[]
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  @@map("users")
}

model Post {
  id       Int    @id @default(autoincrement())
  title    String
  content  String?
  authorId Int    @map("author_id")
  author   User   @relation(
    fields: [authorId],
    references: [id],
    onDelete: Cascade
  )

  @@index([authorId])
  @@map("posts")
}

@map связывает поле Prisma с колонкой PostgreSQL, а @@map — модель с таблицей.

Это позволяет использовать:

JavaScript: createdAt
PostgreSQL: created_at

Prisma Client

import { PrismaClient } from '@prisma/client';

export const prisma = new PrismaClient({
  log: ['warn', 'error'],
});

В долгоживущем серверном процессе обычно создают один экземпляр клиента, а не новый экземпляр на каждый запрос.

CRUD

Создание:

const user = await prisma.user.create({
  data: {
    email: 'user@example.com',
    name: 'Анна',
  },
});

Поиск:

const user = await prisma.user.findUnique({
  where: {
    email: 'user@example.com',
  },
});

Фильтрация и сортировка:

const users = await prisma.user.findMany({
  where: {
    name: {
      contains: 'Ан',
      mode: 'insensitive',
    },
  },
  orderBy: {
    createdAt: 'desc',
  },
  take: 20,
});

Обновление:

const user = await prisma.user.update({
  where: {
    id: 42,
  },
  data: {
    name: 'Новое имя',
  },
});

Удаление:

await prisma.user.delete({
  where: {
    id: 42,
  },
});

Загрузка связей

const user = await prisma.user.findUnique({
  where: {
    id: 42,
  },
  select: {
    id: true,
    email: true,
    posts: {
      select: {
        id: true,
        title: true,
      },
      orderBy: {
        id: 'desc',
      },
    },
  },
});

Явный select помогает не загружать лишние поля и вложенные связи.


ORM TypeORM

TypeORM поддерживает шаблоны Data Mapper и Active Record. В TypeScript-проектах модели часто описываются классами и декораторами.

Установка

npm install typeorm pg reflect-metadata

Для декораторов требуется настройка TypeScript:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

Сущность

import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  OneToMany,
} from 'typeorm';

@Entity({ name: 'users' })
export class User {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column({ unique: true })
  email!: string;

  @Column({ nullable: true })
  name!: string | null;

  @CreateDateColumn({
    name: 'created_at',
    type: 'timestamptz',
  })
  createdAt!: Date;

  @OneToMany(() => Post, (post) => post.author)
  posts!: Post[];
}

Связанная сущность:

import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  ManyToOne,
  JoinColumn,
  Index,
} from 'typeorm';

@Entity({ name: 'posts' })
export class Post {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column()
  title!: string;

  @Index()
  @Column({ name: 'author_id' })
  authorId!: number;

  @ManyToOne(() => User, (user) => user.posts, {
    onDelete: 'CASCADE',
  })
  @JoinColumn({ name: 'author_id' })
  author!: User;
}

Подключение

import 'reflect-metadata';
import { DataSource } from 'typeorm';

export const dataSource = new DataSource({
  type: 'postgres',
  url: process.env.DATABASE_URL,
  entities: [User, Post],
  migrations: ['dist/migrations/*.js'],
  synchronize: false,
  logging: false,
});

await dataSource.initialize();

В рабочем окружении обычно используют synchronize: false. Автоматическая синхронизация не заменяет контролируемые миграции.

Репозиторий

const userRepository = dataSource.getRepository(User);

const user = userRepository.create({
  email: 'user@example.com',
  name: 'Анна',
});

await userRepository.save(user);

const found = await userRepository.findOne({
  where: {
    id: user.id,
  },
  relations: {
    posts: true,
  },
});

Для сложных условий доступен QueryBuilder:

const users = await userRepository
  .createQueryBuilder('user')
  .leftJoinAndSelect('user.posts', 'post')
  .where('user.created_at >= :from', { from })
  .andWhere('post.published_at IS NOT NULL')
  .orderBy('user.created_at', 'DESC')
  .take(20)
  .getMany();

ORM Sequelize

Sequelize — ORM для Node.js, поддерживающая PostgreSQL и другие SQL-базы.

Установка

npm install sequelize pg

Подключение

import { Sequelize, DataTypes } from 'sequelize';

export const sequelize = new Sequelize(
  process.env.DATABASE_URL,
  {
    dialect: 'postgres',
    logging: false,
    pool: {
      max: 10,
      min: 0,
      acquire: 5_000,
      idle: 30_000,
    },
  },
);

await sequelize.authenticate();

Модель

export const User = sequelize.define(
  'User',
  {
    id: {
      type: DataTypes.INTEGER,
      autoIncrement: true,
      primaryKey: true,
    },

    email: {
      type: DataTypes.STRING,
      allowNull: false,
      unique: true,
      validate: {
        isEmail: true,
      },
    },

    name: {
      type: DataTypes.STRING,
      allowNull: true,
    },
  },
  {
    tableName: 'users',
    underscored: true,
    timestamps: true,
  },
);

CRUD

const user = await User.create({
  email: 'user@example.com',
  name: 'Анна',
});

const found = await User.findByPk(user.id);

await user.update({
  name: 'Новое имя',
});

await user.destroy();

Методы вроде sync({ alter: true }) удобны для локальных экспериментов, но не должны заменять проверяемые миграции рабочей базы.


Миграции схемы БД

Миграция — версионированное изменение структуры базы данных.

Миграции могут:

Миграции должны:

Миграции Prisma

Создание и применение миграции во время разработки:

npx prisma migrate dev --name create-users-and-posts

Применение готовых миграций в рабочем окружении:

npx prisma migrate deploy

Проверка состояния:

npx prisma migrate status

migrate dev предназначена для разработки. В production обычно применяется migrate deploy.

Миграции TypeORM

Создание миграции на основе изменений сущностей:

npx typeorm migration:generate \
  ./src/migrations/AddUsers \
  -d ./src/data-source.ts

Создание пустой миграции:

npx typeorm migration:create \
  ./src/migrations/AddUserStatus

Применение:

npx typeorm migration:run \
  -d ./dist/data-source.js

Откат последней миграции:

npx typeorm migration:revert \
  -d ./dist/data-source.js

Сгенерированный SQL необходимо проверять перед применением.

Миграции Sequelize

Установка CLI:

npm install --save-dev sequelize-cli

Создание миграции:

npx sequelize-cli migration:generate \
  --name create-users

Применение:

npx sequelize-cli db:migrate

Откат:

npx sequelize-cli db:migrate:undo

Безопасное изменение рабочей схемы

Добавление обязательной колонки в большую таблицу лучше выполнять поэтапно.

Сначала добавить необязательную колонку:

ALTER TABLE users
ADD COLUMN status text;

Затем заполнить существующие строки:

UPDATE users
SET status = 'active'
WHERE status IS NULL;

После проверки добавить значение по умолчанию и NOT NULL:

ALTER TABLE users
ALTER COLUMN status SET DEFAULT 'active';

ALTER TABLE users
ALTER COLUMN status SET NOT NULL;

Для большой таблицы заполнение данных обычно выполняют порциями.

Создание индекса в рабочей системе может потребовать:

CREATE INDEX CONCURRENTLY users_status_idx
ON users(status);

CREATE INDEX CONCURRENTLY нельзя выполнять внутри обычного транзакционного блока.

Если приложение запущено в нескольких экземплярах, не следует разрешать каждому экземпляру одновременно запускать миграции. Их обычно выполняют отдельной задачей развёртывания.


Модели и связи в ORM

Связь один к одному

Пример: у пользователя есть один профиль.

users.id 1 ─── 1 profiles.user_id

В базе данных внешний ключ на стороне профиля должен быть уникальным:

CREATE TABLE profiles (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id bigint NOT NULL UNIQUE
    REFERENCES users(id)
    ON DELETE CASCADE,
  bio text
);

UNIQUE на user_id превращает обычную связь многие-к-одному в один-к-одному.

Связь один ко многим

Один пользователь создаёт много публикаций:

users.id 1 ─── N posts.author_id
CREATE TABLE posts (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  author_id bigint NOT NULL
    REFERENCES users(id),
  title text NOT NULL
);

CREATE INDEX posts_author_id_idx
ON posts(author_id);

PostgreSQL автоматически создаёт индексы для PRIMARY KEY и UNIQUE, но не создаёт индекс для каждого внешнего ключа.

Индекс posts.author_id полезен для:

Связь многие ко многим

Публикация может иметь много тегов, а тег может относиться к разным публикациям:

posts 1 ─── N post_tags N ─── 1 tags
CREATE TABLE post_tags (
  post_id bigint NOT NULL
    REFERENCES posts(id)
    ON DELETE CASCADE,

  tag_id bigint NOT NULL
    REFERENCES tags(id)
    ON DELETE CASCADE,

  PRIMARY KEY (post_id, tag_id)
);

CREATE INDEX post_tags_tag_id_idx
ON post_tags(tag_id);

Если связь имеет собственные атрибуты, её лучше моделировать как отдельную сущность:

post_id
tag_id
assigned_at
assigned_by
position

Поведение при удалении

Настройка Поведение
ON DELETE RESTRICT Запрещает удаление родителя
ON DELETE CASCADE Удаляет зависимые строки
ON DELETE SET NULL Обнуляет внешний ключ
ON DELETE NO ACTION Проверяет ограничение по правилам PostgreSQL

CASCADE следует использовать осознанно: удаление одной строки может затронуть большой граф связанных данных.

Проблема N+1

Проблема N+1 возникает, когда приложение:

  1. выполняет один запрос для получения 100 пользователей;
  2. выполняет ещё 100 запросов для получения публикаций каждого пользователя.

Решения:

Не следует автоматически загружать все связи. Это может создать тяжёлый SQL-запрос и большой ответ.


Транзакции через pg

Транзакция объединяет несколько операций в единое целое: либо фиксируются все изменения, либо не фиксируется ни одно.

Основной шаблон

export async function transferMoney(
  fromAccountId,
  toAccountId,
  amount,
) {
  const client = await pool.connect();

  try {
    await client.query('BEGIN');

    const debitResult = await client.query(
      `UPDATE accounts
       SET balance = balance - $2
       WHERE id = $1
         AND balance >= $2
       RETURNING balance`,
      [fromAccountId, amount],
    );

    if (debitResult.rowCount === 0) {
      throw new Error(
        'Недостаточно средств или счёт не найден',
      );
    }

    const creditResult = await client.query(
      `UPDATE accounts
       SET balance = balance + $2
       WHERE id = $1
       RETURNING balance`,
      [toAccountId, amount],
    );

    if (creditResult.rowCount === 0) {
      throw new Error('Счёт получателя не найден');
    }

    await client.query(
      `INSERT INTO transfers (
         from_account_id,
         to_account_id,
         amount
       )
       VALUES ($1, $2, $3)`,
      [fromAccountId, toAccountId, amount],
    );

    await client.query('COMMIT');

    return {
      fromBalance: debitResult.rows[0].balance,
      toBalance: creditResult.rows[0].balance,
    };
  } catch (error) {
    await client.query('ROLLBACK').catch(
      (rollbackError) => {
        console.error('Ошибка ROLLBACK', rollbackError);
      },
    );

    throw error;
  } finally {
    client.release();
  }
}

Все команды транзакции должны выполняться через один объект client.

Неправильно:

const client = await pool.connect();

await client.query('BEGIN');
await pool.query('UPDATE accounts ...');
await client.query('COMMIT');

pool.query() может использовать другое соединение, которое не участвует в транзакции.

Уровни изоляции

await client.query(
  'BEGIN ISOLATION LEVEL SERIALIZABLE',
);

Основные уровни PostgreSQL:

Блокировка строк

Если изменение зависит от текущего состояния строки, может потребоваться блокировка:

const result = await client.query(
  `SELECT id, balance
   FROM accounts
   WHERE id = $1
   FOR UPDATE`,
  [accountId],
);

FOR UPDATE блокирует выбранные строки от конкурирующих изменений до завершения транзакции.

Транзакции следует делать короткими. Внутри транзакции не рекомендуется:


Транзакции в Prisma

Интерактивная транзакция:

const result = await prisma.$transaction(async (tx) => {
  const user = await tx.user.create({
    data: {
      email: 'user@example.com',
    },
  });

  const post = await tx.post.create({
    data: {
      title: 'Первая публикация',
      authorId: user.id,
    },
  });

  return {
    user,
    post,
  };
});

Внутри callback необходимо использовать tx, а не глобальный объект prisma.

Последовательность независимых ORM-операций также можно передать массивом:

const [user, post] = await prisma.$transaction([
  prisma.user.create({
    data: {
      email: 'user@example.com',
    },
  }),

  prisma.post.update({
    where: {
      id: 10,
    },
    data: {
      title: 'Обновлённый заголовок',
    },
  }),
]);

Транзакции в TypeORM

const result = await dataSource.transaction(
  async (manager) => {
    const userRepository = manager.getRepository(User);
    const postRepository = manager.getRepository(Post);

    const user = await userRepository.save({
      email: 'user@example.com',
    });

    const post = await postRepository.save({
      title: 'Первая публикация',
      authorId: user.id,
    });

    return {
      user,
      post,
    };
  },
);

Внутри транзакции нужно использовать переданный manager, а не обычные глобальные репозитории.


Транзакции в Sequelize

const result = await sequelize.transaction(
  async (transaction) => {
    const user = await User.create(
      {
        email: 'user@example.com',
      },
      {
        transaction,
      },
    );

    const post = await Post.create(
      {
        title: 'Первая публикация',
        authorId: user.id,
      },
      {
        transaction,
      },
    );

    return {
      user,
      post,
    };
  },
);

Если callback завершается успешно, Sequelize фиксирует транзакцию. Если callback выбрасывает исключение, транзакция откатывается.


Обработка ошибок PostgreSQL

Ошибки PostgreSQL следует распознавать по пятисимвольному коду SQLSTATE, а не по тексту сообщения.

Частые коды:

SQLSTATE Значение
23505 Нарушение UNIQUE
23503 Нарушение внешнего ключа
23502 Нарушение NOT NULL
23514 Нарушение CHECK
22P02 Некорректное представление типа
40001 Ошибка сериализации
40P01 Взаимная блокировка
42P01 Таблица не существует
42703 Колонка не существует
57014 Выполнение команды отменено

Обработка ошибки pg

try {
  await pool.query(
    `INSERT INTO users (email)
     VALUES ($1)`,
    [email],
  );
} catch (error) {
  if (
    error.code === '23505' &&
    error.constraint === 'users_email_key'
  ) {
    throw new ConflictError(
      'Пользователь с таким email уже существует',
    );
  }

  throw error;
}

Объект ошибки может содержать:

Сырой объект ошибки не следует возвращать клиенту. Он может раскрывать:

Преобразование ошибки в HTTP-ответ

export function mapDatabaseError(error) {
  switch (error.code) {
    case '23505':
      return {
        status: 409,
        code: 'RESOURCE_ALREADY_EXISTS',
        message: 'Запись с такими данными уже существует',
      };

    case '23503':
      return {
        status: 409,
        code: 'RELATED_RESOURCE_CONFLICT',
        message: 'Операция нарушает связь между данными',
      };

    case '23502':
    case '23514':
    case '22P02':
      return {
        status: 400,
        code: 'INVALID_DATA',
        message: 'Переданы некорректные данные',
      };

    case '57014':
      return {
        status: 503,
        code: 'DATABASE_TIMEOUT',
        message: 'База данных не успела выполнить запрос',
      };

    default:
      return {
        status: 500,
        code: 'INTERNAL_ERROR',
        message: 'Внутренняя ошибка',
      };
  }
}

Ограничение вместо предварительного SELECT

Такой код не гарантирует уникальность:

const existing = await pool.query(
  'SELECT id FROM users WHERE email = $1',
  [email],
);

if (existing.rowCount === 0) {
  await pool.query(
    'INSERT INTO users (email) VALUES ($1)',
    [email],
  );
}

Между SELECT и INSERT другая транзакция может вставить такую же запись.

Надёжный подход:

  1. создать ограничение UNIQUE;
  2. выполнить INSERT;
  3. обработать ошибку 23505.
ALTER TABLE users
ADD CONSTRAINT users_email_key UNIQUE (email);

База данных должна быть окончательным механизмом проверки целостности.


Обработка ошибок Prisma

Prisma использует собственные коды известных ошибок.

Пример нарушения уникальности:

import { Prisma } from '@prisma/client';

try {
  await prisma.user.create({
    data: {
      email,
    },
  });
} catch (error) {
  if (
    error instanceof
      Prisma.PrismaClientKnownRequestError &&
    error.code === 'P2002'
  ) {
    throw new ConflictError('Email уже используется');
  }

  throw error;
}

ORM-ошибка может оборачивать исходную ошибку PostgreSQL, поэтому при журналировании полезно сохранять:

Секретные параметры и персональные данные в журнал записывать не следует.


Обработка ошибок TypeORM

TypeORM предоставляет QueryFailedError:

import { QueryFailedError } from 'typeorm';

try {
  await userRepository.save(user);
} catch (error) {
  if (error instanceof QueryFailedError) {
    const driverError = (
      error as QueryFailedError & {
        driverError: {
          code?: string;
        };
      }
    ).driverError;

    if (driverError.code === '23505') {
      throw new ConflictError(
        'Email уже используется',
      );
    }
  }

  throw error;
}

Обработка ошибок Sequelize

Sequelize предоставляет специализированные классы:

import { UniqueConstraintError } from 'sequelize';

try {
  await User.create({
    email,
  });
} catch (error) {
  if (error instanceof UniqueConstraintError) {
    throw new ConflictError(
      'Email уже используется',
    );
  }

  throw error;
}

Повтор транзакций

При уровне SERIALIZABLE транзакция может завершиться ошибкой 40001. PostgreSQL также может завершить одну из транзакций ошибкой 40P01 при взаимной блокировке.

Такие транзакции иногда можно повторить:

const RETRYABLE_CODES = new Set([
  '40001',
  '40P01',
]);

export async function withTransaction(
  work,
  maxAttempts = 3,
) {
  for (
    let attempt = 1;
    attempt <= maxAttempts;
    attempt += 1
  ) {
    const client = await pool.connect();

    try {
      await client.query('BEGIN');

      const result = await work(client);

      await client.query('COMMIT');

      return result;
    } catch (error) {
      await client
        .query('ROLLBACK')
        .catch(() => {});

      const shouldRetry =
        RETRYABLE_CODES.has(error.code) &&
        attempt < maxAttempts;

      if (!shouldRetry) {
        throw error;
      }
    } finally {
      client.release();
    }
  }

  throw new Error('Транзакция не была выполнена');
}

Повторять следует только операции, безопасные для повторного выполнения.

Если внутри операции отправляется письмо или выполняется внешний платёж, повтор транзакции не должен случайно повторять внешнее действие. Для таких случаев используют идемпотентность или шаблон transactional outbox.


Тайм-ауты запросов

Запрос не должен выполняться неограниченно долго.

Тайм-аут можно установить внутри транзакции:

const client = await pool.connect();

try {
  await client.query('BEGIN');

  await client.query(
    "SET LOCAL statement_timeout = '3s'",
  );

  const result = await client.query(
    `SELECT id, email
     FROM users
     WHERE status = $1`,
    ['active'],
  );

  await client.query('COMMIT');

  return result.rows;
} catch (error) {
  await client
    .query('ROLLBACK')
    .catch(() => {});

  throw error;
} finally {
  client.release();
}

SET LOCAL действует только до конца текущей транзакции.

Полезные параметры PostgreSQL:


Типы PostgreSQL и JavaScript

Некоторые типы требуют дополнительного внимания.

bigint

JavaScript Number не может точно представить все значения PostgreSQL bigint. Поэтому драйвер может вернуть такое значение строкой:

{
  id: '9223372036854775807'
}

Не следует автоматически выполнять:

const id = Number(row.id);

Для больших значений можно использовать строку или BigInt:

const id = BigInt(row.id);

numeric

numeric также часто возвращается строкой, поскольку десятичное число может иметь точность, недоступную обычному Number.

{
  amount: '1234567890.123456'
}

Для денежных значений часто используют:

Имена колонок

PostgreSQL обычно использует snake_case:

SELECT created_at
FROM users;

JavaScript-приложение может ожидать camelCase. Через pg можно использовать псевдоним:

SELECT created_at AS "createdAt"
FROM users;

TLS и секреты

При удалённом подключении к PostgreSQL обычно требуется TLS.

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,

  ssl:
    process.env.NODE_ENV === 'production'
      ? {
          rejectUnauthorized: true,
        }
      : false,
});

Если поставщик базы предоставляет корневой сертификат:

import fs from 'node:fs';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,

  ssl: {
    ca: fs.readFileSync(
      process.env.PG_CA_FILE,
      'utf8',
    ),
    rejectUnauthorized: true,
  },
});

Настройка:

rejectUnauthorized: false

отключает проверку сертификата и снижает безопасность соединения. Её не следует использовать как универсальное production-решение.

В логи нельзя записывать:


Производительность

Не выбирать лишние поля

Вместо:

SELECT *
FROM users;

лучше явно перечислить колонки:

SELECT id, email, name
FROM users;

Ограничивать результат

const result = await pool.query(
  `SELECT id, title, created_at
   FROM posts
   WHERE (created_at, id) < ($1, $2)
   ORDER BY created_at DESC, id DESC
   LIMIT $3`,
  [cursorDate, cursorId, 50],
);

Для больших наборов keyset pagination обычно устойчивее большого OFFSET.

Анализировать запросы

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, email
FROM users
WHERE email = 'user@example.com';

ANALYZE фактически выполняет запрос. Для UPDATE, DELETE и других изменяющих команд это нужно учитывать.

Наблюдать за пулом

У pg.Pool доступны основные показатели:

const metrics = {
  total: pool.totalCount,
  idle: pool.idleCount,
  waiting: pool.waitingCount,
};

Постоянный рост waitingCount может указывать на:

Размер пула нельзя бесконечно увеличивать. Большое количество параллельных запросов может ухудшить производительность базы.


Частые ошибки

Создание пула на каждый запрос

Неправильно:

app.get('/users', async (req, res) => {
  const pool = new Pool({
    connectionString: process.env.DATABASE_URL,
  });

  const result = await pool.query(
    'SELECT id FROM users',
  );

  await pool.end();

  res.json(result.rows);
});

Пул должен создаваться один раз и переиспользоваться.

Клиент не возвращается в пул

Неправильно:

const client = await pool.connect();

const result = await client.query(
  'SELECT id FROM users',
);

return result.rows;

Правильно:

const client = await pool.connect();

try {
  const result = await client.query(
    'SELECT id FROM users',
  );

  return result.rows;
} finally {
  client.release();
}

Использование разных соединений в транзакции

Все команды от BEGIN до COMMIT или ROLLBACK должны выполняться через один клиент.

Автоматическая синхронизация production-схемы

TypeORM synchronize и Sequelize sync({ alter: true }) не заменяют миграции.

Неограниченная загрузка связей

Глубокий include или eager loading может создать тяжёлый запрос и огромный результат.

Проверка уникальности только в приложении

Предварительный SELECT не защищает от конкурентных вставок. Необходимо ограничение UNIQUE.

Длительные транзакции

Открытая транзакция удерживает ресурсы и блокировки. Внешние запросы и продолжительные операции следует выносить за её пределы.

Слишком большой пул

Размер пула необходимо рассчитывать с учётом всех экземпляров приложения, фоновых задач и лимита PostgreSQL.


Краткий чек-лист

Перед выпуском Node.js-приложения с PostgreSQL следует проверить:


Итог

pg подходит, когда нужен прозрачный SQL, полный контроль над запросами и прямой доступ к возможностям PostgreSQL.

Prisma удобна для типизированной разработки и декларативной схемы. TypeORM хорошо вписывается в проекты с классами и декораторами. Sequelize предоставляет традиционный ORM-подход с моделями и экземплярами записей.

Выбор библиотеки не отменяет основных требований к работе с базой данных. Надёжная интеграция строится на параметризованных запросах, разумном пуле соединений, версионированных миграциях, коротких транзакциях, ограничениях на уровне PostgreSQL и системной обработке ошибок.