lenec ru

← все посты

Drizzle ORM с Postgres в TypeScript: настройка проекта с нуля

16K

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

Сразу оговорю: статья про Postgres. Под SQLite и MySQL общая логика та же, но конкретные импорты и типы колонок отличаются. Версии: Node 20, TypeScript 5.4, drizzle-orm 0.30+, drizzle-kit 0.21+, Postgres 16. Если у тебя более ранний drizzle-kit, часть команд будет звучать по-другому, проверь свой package.json.

Зачем вообще Drizzle

Главное, ради чего я к нему пришёл, это контроль над SQL. Drizzle не прячет от тебя запрос: ты пишешь схему на TS, получаешь типизированный builder, и в логе сервера видишь именно тот SQL, который уйдёт в БД. Никакого $queryRaw, чтобы починить план запроса, никаких сюрпризов с N+1 от ленивых relations.

Второй плюс — миграции. Они генерируются как обычные SQL-файлы, которые можно прочитать, поправить руками и положить в репозиторий. Никакой бинарной schema engine, которая решает за тебя, как именно добавлять колонку.

Минус честно тоже один: если ты ждёшь от ORM магических relations с автозагрузкой и одной строкой findMany, в Drizzle чуть больше ручной работы. Зато потом эта работа отливается в предсказуемый SQL.

Установка зависимостей

В пустом проекте делаю так. pg — это драйвер node-postgres, его и беру для серверного приложения. Если у тебя serverless и хочется websocket-pool, есть варианты на postgres и @neondatabase/serverless, но для обычного Node-сервиса pg — самый предсказуемый выбор.

pnpm add drizzle-orm pg
pnpm add -D drizzle-kit @types/pg tsx dotenv

tsx я держу как раннер для скриптов миграции и сидов: запускает TS без отдельного билда, как раз удобно для CLI-задач.

Конфиг drizzle-kit

Создаю файл drizzle.config.ts в корне проекта. Это конфиг для CLI, который генерирует миграции и пушит схему.

import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  schema: './src/db/schema.ts',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
  verbose: true,
  strict: true,
});

Параметр strict важен: он не даст молча применить миграцию, в которой drizzle-kit что-то не понял. Лучше один раз увидеть подсказку, чем потом разбирать продовский диф.

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

Подключение я выношу в src/db/index.ts и переиспользую один pool на весь процесс. На каждом запросе создавать клиента — верный способ упереться в лимит соединений Postgres.

import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';

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

export const db = drizzle(pool, { schema, logger: process.env.NODE_ENV !== 'production' });

На max: 10 я обычно ставлю на single-instance API. Если приложений несколько и они шарят одну БД, общая сумма pool-ов всех инстансов не должна превышать max_connections Postgres минус запас на админ-сессии.

Первая схема

Начинаю с самой ходовой пары: пользователи и посты. Положу в src/db/schema.ts:

import { pgTable, serial, text, varchar, timestamp, integer, index, uniqueIndex } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  email: varchar('email', { length: 320 }).notNull(),
  name: varchar('name', { length: 120 }).notNull(),
  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
}, (t) => ({
  emailIdx: uniqueIndex('users_email_idx').on(t.email),
}));

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  authorId: integer('author_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
  title: varchar('title', { length: 160 }).notNull(),
  body: text('body').notNull(),
  publishedAt: timestamp('published_at', { withTimezone: true }),
}, (t) => ({
  authorIdx: index('posts_author_idx').on(t.authorId),
}));

Что важно отметить:

  • varchar для коротких полей с понятным верхним пределом, text для тела поста. Postgres хранит их одинаково, но varchar(160) — это документация и валидация, доступная и SQL-консоли.
  • withTimezone: true на временных метках. Без этого получишь timestamp without time zone, который потом будет давать сюрпризы при джойнах с другими сервисами.
  • Индекс users_email_idx уникальный, потому что искать по email мы будем при логине каждый раз.

Первая миграция

Команды я держу в package.json, чтобы не вспоминать аргументы.

{
  "scripts": {
    "db:generate": "drizzle-kit generate",
    "db:migrate": "tsx src/db/migrate.ts",
    "db:studio": "drizzle-kit studio"
  }
}

Файл src/db/migrate.ts — простой раннер на основе встроенной утилиты Drizzle. Я не люблю гонять drizzle-kit push на проде: push хорош для локального прототипа, а для деплоя нужны именно миграционные файлы, которые видно в git.

import 'dotenv/config';
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { Pool } from 'pg';

async function main() {
  const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 1 });
  const db = drizzle(pool);
  await migrate(db, { migrationsFolder: './drizzle' });
  await pool.end();
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});

Дальше — рутина: pnpm db:generate создаёт SQL в ./drizzle, pnpm db:migrate накатывает на базу. Если поправил схему — повторил пару команд, миграция применилась.

Запросы

Drizzle даёт два уровня API: SQL-builder и query API. Для типичных операций они оба удобны, выбор — вопрос вкуса.

import { eq, desc } from 'drizzle-orm';
import { db } from './db';
import { users, posts } from './db/schema';

export async function getRecentPostsByAuthor(authorId: number, limit = 20) {
  return db
    .select({
      id: posts.id,
      title: posts.title,
      publishedAt: posts.publishedAt,
    })
    .from(posts)
    .where(eq(posts.authorId, authorId))
    .orderBy(desc(posts.publishedAt))
    .limit(limit);
}

Возвращаемый тип — массив объектов, в котором ровно те поля, что я перечислил в select. Никаких лишних колонок, никаких any, всё обнаружится на этапе компиляции TS.

Что я бы добавил с самого начала

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

  • Транзакции через db.transaction. Заверни любой сценарий с двумя записями в транзакцию, даже если кажется простым. Я ловил рассинхрон между users и profiles ровно потому, что когда-то поленился.
  • Префиксы для индексов. Drizzle сам не умеет проверять уникальность имён индексов — ловишь это уже на миграции. Сразу пиши posts_author_idx, а не idx, иначе через год их будет двадцать с одним именем.
  • Запрет на any в tsconfig. Drizzle тянет типы из схемы, и если разрешить any в репо, половина гарантий уходит в никуда.

Куда копать дальше

Когда базовая часть встала, имеет смысл посмотреть на relations API: он даёт понятный интерфейс для джойнов и встраивается в TS без ручной типизации. Я разбираю его подводные камни в отдельной статье; если резюмировать в строчке — relations сильно облегчают типичный findMany, но плохо дружат с гипертонкими выборками, где важен каждый колонка-байт.

Ещё стоит подключить drizzle-zod, чтобы получать схемы валидации напрямую из таблиц. Удобно для API, не приходится дублировать поля в zod-схемах. И посмотреть на drizzle-kit pull — он умеет делать ровно противоположное генерации, вытащить схему из существующей БД, что выручает при подъёме legacy-проекта.

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

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.