Собираем раннер миграций: журнал, порядок, контрольная сумма

база≈ 17 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Прошлая статья кончилась на полуслове: drizzle-kit export печатает готовый CREATE TABLE, но это ещё не миграция. Миграция отличается от печати схемы двумя вещами — её кладут в репозиторий отдельным файлом, а то, что она применена, отмечают в самой базе. Почему не проще: сравнить описание с базой и дописать разницу одной командой? С этого вопроса статья и начинается. Дальше вы соберёте раннер миграций на той же таблице, что и всю главу: сперва он сломается на втором выкате, потом заведёт журнал, потом научится читать имена файлов в нужном порядке, а под конец поймает миграцию, которую поправили уже после применения. И в самом конце — настоящий drizzle-kit: те же файлы, тот же журнал в базе и две ловушки, о которых сам выкат не скажет ни слова.

Разделы темы:·

Описание таблицы лежит в репозитории значением, а в базе — настоящими колонками. Напрашивается простое: сравнить два списка и дописать разницу. Такая команда у Drizzle есть — drizzle-kit push, — и для черновика на своей машине она удобна: правите схему, жмёте push, база догоняет. Беда начинается там, где в таблице лежат чужие данные.

Вот вся задача, которую решает автоматическая сверка. В репозитории колонку переименовали: была title, стала heading. Сверка видит два списка имён — и больше ничего. Прогноз: сколько у неё способов привести базу к новому описанию и чем эти способы отличаются для тех, кто уже написал заметки?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Обе перемены законны, обе приводят колонки к описанию — только одна сохранила заголовки, а другая раздала всем null. Выбрать между ними сверка не может: в схеме написано, что должно получиться, и не написано, как туда идти. Разницу между «переименовали» и «удалили и завели новую» знает лишь человек, который правил схему.

push хорош, пока база пуста: черновик на своей машине переживёт и потерянную колонку. Боевая база чужие заметки не переживёт.

Отсюда и берётся миграция. Миграция — это не картинка «как должно стать», а записанный шаг «что сделать». Его пишут один раз, руками или генератором, и кладут в репозиторий файлом: рядом с кодом, под тем же ревью, в той же истории. Дальше по этим файлам догоняют все базы — ваша, соседа, тестовая и боевая.

Соберём раннер: список миграций и цикл, который их выполняет. База в модели — карта «имя таблицы → таблица»: сами таблицы те, что вы собирали в начале главы, а карта над ними здесь новая. Шаг миграции — обычная функция, которая с этой картой что-то делает. Создание таблицы у настоящей базы не идемпотентно: CREATE TABLE notes во второй раз отвечает кодом 42P07, и модель повторяет это дословно.

Первый выкат идёт по пустой базе, потом в таблицу ложится заметка, а потом тот же код выкатывают ещё раз — обычное дело, если миграции прогоняют на каждый деплой. Прогноз: сколько миграций успеет пройти второй выкат и сколько заметок останется в таблице?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

Соблазн понятный: написать каждую миграцию так, чтобы её можно было повторять, — CREATE TABLE IF NOT EXISTS, ADD COLUMN IF NOT EXISTS. Он не спасает.

Половина миграций — не создание, а перенос данных: разложить полное имя на два поля, проставить slug всем старым заметкам, перевести суммы из рублей в копейки. У такого шага нет формы «если ещё не сделано»: UPDATE notes SET amount = amount * 100, выполненный дважды, даёт не копейки, а в сто раз больше, и никакой IF NOT EXISTS про это не знает. Признак «применено» приходится хранить отдельно.

Значит, раннеру нужна память: какие шаги эта база уже видела.

Где хранить список применённого? Файл рядом с кодом не годится: репозиторий один, а баз несколько — ваша, соседа, тестовая, боевая. Такой файл сказал бы «0003 применена» всем сразу, хотя видела её только ваша машина. Список принадлежит не коду, а базе — поэтому и лежит в базе, обычной таблицей, которую раннер заводит себе сам при первом выкате.

Лестницу миграций вы уже проходили на телефоне: там номер схемы лежал в том же значении, что и данные, и шаги шли подряд от него. Механика та же, спрашивают о другом: телефон один и база у него одна, а здесь один репозиторий выкатывают в несколько баз, и «докуда дошли» каждая отвечает сама. Отсюда и таблица вместо одного числа: у отметки появится и имя, и контрольная сумма.

В модели журнал — та же таблица, с уникальным индексом по имени: дважды отметить одну миграцию она не даст. Прогноз: что напечатает сегодняшний выкат на машине, где вчера прогнали первые две миграции, и что — на боевой базе, заведённой сегодня с нуля?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Код один, список миграций один, а сделал он разное — потому что спрашивал не репозиторий, а базу. dev догнал репозиторий одной миграцией, prod — тремя, и в конце обе базы в одном состоянии. Это и есть главное свойство миграций: история общая, а сколько из неё применено — свой ответ у каждой базы.

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

Шаг и отметка должны попадать в базу вместе. Если шаг доехал, а отметка нет, следующий выкат повторит его со всеми последствиями из прошлого раздела; если отметка легла раньше шага, а шаг упал, база останется без него навсегда — с этого начинается первая задача. В Postgres команды вроде CREATE TABLE работают внутри транзакции, так что оба действия кладут в одну транзакцию — ту самую, которую вы собирали в статье про откат.

Раннер берёт миграции из каталога, а каталог отдаёт файлы в своём порядке. Значит, порядок раннер задаёт сам, и единственное, что у него для этого есть, — имя файла. Отсюда правило, которое выглядит бюрократией, пока в него не упрёшься: имя начинается с номера, а номер выровнен нулями.

Прогноз: три набора имён, три прогона — какие из них доедут до конца и на чём споткнутся остальные?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Имена по смыслу сортируются по алфавиту: add-slug-index встаёт перед create-notes, и индекс просят у таблицы, которой ещё нет, — 42P01. Номер порядок чинит, но только пока он одной ширины: «10» и «9» сравниваются как строки, знак за знаком, и «1» меньше «9». Поэтому номер и выравнивают нулями: у drizzle-kit он четырёхзначный, и первая миграция называется 0000, а не 1.

Но номер — не единственный способ расставить миграции. Drizzle держит рядом с файлами собственный журнал репозитория, и в нём у каждой миграции записано время, и порядок раннер берёт оттуда, а не из имён файлов. Раннер может сравнивать с базой набор имён, а может смотреть только на самую позднюю отметку и применять всё, что новее. Пока ветки сливают по очереди, разницы между этими правилами не видно. Прогноз: у какого из двух раннеров в базе появится таблица authors?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Раннер по порогу пропустил 0001 навсегда: её время меньше времени последней применённой, и следующий выкат рассудит так же. Раннер по именам увидел, что такого имени в журнале нет, и применил. Оба правила простые, но первое делает молчаливое допущение — что миграции приезжают в порядке времени, — и ветка, слитая позже соседней, это допущение ломает. Какое из двух правил у настоящего drizzle-kit, увидим в последнем разделе.

Отметка по имени отвечает на вопрос «эту миграцию уже применяли?». Она не отвечает на другой: «применяли ли именно это?». Имя файла держится, а текст внутри может смениться: кто-то заметил опечатку, кто-то «просто поправил» вчерашнюю миграцию вместо того, чтобы написать новую. База при этом остаётся такой, какой её сделал прежний текст.

Лекарство — хранить в журнале не только имя, но и контрольную сумму текста. Настоящий раннер берёт sha256 от файла; в модели миграция — функция, и суммой послужит хеш её исходника: важна не стойкость, а то, что число меняется вместе с текстом. Прогноз: что напечатает про 0002 второй выкат и что после него окажется в базе?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Раннер назвал файл и обе суммы — этого хватает, чтобы понять, что репозиторий и база разошлись. А последняя строка показывает цену молчания: в базе стоит уникальный индекс, которого в репозитории уже нет, и вторая заметка с тем же slug отказывает кодом 23505 — хотя по нынешнему тексту миграции уникальности там быть не должно.

Откатить и применить заново — плохой ответ: на боевой базе откат применённой миграции означает DROP того, чем прямо сейчас пользуются. Ответ скучный: поправка едет новой миграцией, с новым номером. Старая остаётся в истории такой, какой её применили, — история на то и история. Редактировать можно ровно одну миграцию: ту, которую вы ещё никуда не выкатывали, включая собственную машину.

Всё собранное выше есть у drizzle-kit, и под теми же именами. Начнём с того, чем generate отличается от export из прошлой статьи: export печатает схему целиком в поток — посмотреть и закрыть, — а generate пишет файлы. Схема берётся та же самая. Прогноз: сколько файлов появится в каталоге миграций и что окажется в журнале?

прогон node 26.5.0 · 23 сентября 2026
Что drizzle-kit generate кладёт в каталог миграций и как выглядит его журнал
import { execFileSync } from 'node:child_process';
import { readdirSync, readFileSync } from 'node:fs';

// без --name имя файлу выбирает сам drizzle-kit, из своего списка слов
execFileSync('drizzle-kit', ['generate', '--name', 'init'], { stdio: 'inherit' });
console.log();

const каталог = new URL('./drizzle/', import.meta.url);
console.log('--- что появилось в drizzle/');
for (const имя of readdirSync(каталог, { recursive: true, encoding: 'utf8' }).sort()) console.log(`  ${имя}`);

const показать = (имя: string) => {
  console.log(`--- drizzle/${имя}`);
  console.log(readFileSync(new URL(имя, каталог), 'utf8').trimEnd());
};
показать('meta/_journal.json');
показать('0000_init.sql');
node generate.ts
рядом лежит общий код, без него команда не выполнится: ../_shared/p6-drizzle/schema.ts

Файлов три. 0000_init.sql — тот самый CREATE TABLE, слово в слово как у export: это и есть шаг, который поедет в базу. meta/0000_snapshot.json — снимок схемы: в следующий раз generate сравнит новое описание не с базой, а с этим снимком, и потому ему по-прежнему не нужно ни одного соединения. meta/_journal.json — журнал репозитория: номер, тег (имя файла без расширения) и when, время генерации в миллисекундах. Имя файла здесь задано флагом --name; без него drizzle-kit придумывает его сам из своего списка слов, и в репозиторий приезжает что-нибудь вроде 0000_fantastic_puck.sql.

Теперь второй журнал — тот, что в базе. Команда drizzle-kit migrate подключается к базе, заводит себе схему drizzle с таблицей __drizzle_migrations и прогоняет то, чего в ней ещё нет. Запустим её дважды подряд на одной базе, с двумя миграциями в каталоге. Прогноз: что напечатает второй заход и сколько строк окажется в таблице журнала?

прогон под базой PostgreSQL 18.4 · node 26.5.0 · 23 сентября 2026
Две миграции, два захода migrate подряд по одной базе и таблица журнала в ней самой
import { execFileSync } from 'node:child_process';
import pg from 'pg';

// адрес базы — первой строкой и без пароля: видно, куда ушли запросы
const url = new URL(process.env.DATABASE_URL!);
url.password = '';
console.log('база:', url.href);

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

const заход = (какой: string) => {
  console.log(`--- ${какой}: drizzle-kit migrate`);
  execFileSync('drizzle-kit', ['migrate'], { stdio: 'inherit' });
  // drizzle-kit не переводит строку после последнего сообщения
  console.log();
};

const таблицы = async () => {
  const { rows } = await client.query(
    `SELECT table_schema || '.' || table_name AS имя FROM information_schema.tables
     WHERE table_schema IN ('public', 'drizzle') ORDER BY 1`,
  );
  console.log('  таблицы базы:', rows.map((row) => row.имя).join(', '));
};

// журнал drizzle: схема drizzle, таблица __drizzle_migrations, в ней id, hash и created_at
const журнал = async () => {
  const { rows } = await client.query('SELECT id, hash, created_at FROM drizzle.__drizzle_migrations ORDER BY id');
  console.log(`  в drizzle.__drizzle_migrations строк: ${rows.length}`);
  for (const row of rows) console.log(`    id ${row.id}, hash ${row.hash.slice(0, 16)}…, created_at ${row.created_at}`);
};

заход('первый');
await таблицы();
await журнал();

заход('второй');
await журнал();

await client.end();
node migrate.ts

Второй заход не применил ничего — и сказал ровно то же, что первый: «migrations applied successfully!». Это первое, что стоит запомнить про любой раннер миграций: успешное сообщение не значит, что что-то применили, и сколько именно применено, видно только по журналу. В журнале две строки, по одной на миграцию: created_at — это when из журнала репозитория, перенесённое как есть, а hash — sha256 от всего текста файла.

Раз хеш записан, кажется, что drizzle-kit сверяет тексты. Проверим обе ловушки разом. Сначала поправим уже применённую миграцию — допишем колонке body слово NOT NULL. Потом впишем в журнал репозитория миграцию с временем между двумя уже применёнными: так выглядит ветка, которую отвели раньше соседней, а слили позже. Прогноз: что скажет второй заход migrate, что станет с журналом базы и появится ли таблица authors?

прогон под базой PostgreSQL 18.4 · node 26.5.0 · 23 сентября 2026
Журнал базы и её таблицы после правки применённой миграции и после миграции, приехавшей задним числом
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import pg from 'pg';

// адрес базы — первой строкой и без пароля: видно, куда ушли запросы
const url = new URL(process.env.DATABASE_URL!);
url.password = '';
console.log('база:', url.href);

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

const заход = (какой: string) => {
  console.log(`--- ${какой} заход: drizzle-kit migrate`);
  execFileSync('drizzle-kit', ['migrate'], { stdio: 'inherit' });
  // drizzle-kit не переводит строку после последнего сообщения
  console.log();
};

const файл = (имя: string) => new URL(`./drizzle/${имя}`, import.meta.url);
const текст = (имя: string) => readFileSync(файл(имя), 'utf8');
// ровно так считает контрольную сумму сам drizzle: sha256 от всего текста файла
const сумма = (имя: string) => createHash('sha256').update(текст(имя)).digest('hex').slice(0, 12);

const журнал = async () => {
  const { rows } = await client.query('SELECT hash, created_at FROM drizzle.__drizzle_migrations ORDER BY id');
  for (const row of rows) console.log(`  журнал базы: hash ${row.hash.slice(0, 12)}…, created_at ${row.created_at}`);
};

const таблицы = async () => {
  const { rows } = await client.query(
    `SELECT table_name AS имя FROM information_schema.tables WHERE table_schema = 'public' ORDER BY 1`,
  );
  const { rows: колонки } = await client.query(
    `SELECT is_nullable FROM information_schema.columns WHERE table_name = 'notes' AND column_name = 'body'`,
  );
  console.log(`  таблицы: ${rows.map((row) => row.имя).join(', ')}; notes.body is_nullable = ${колонки[0].is_nullable}`);
};

заход('первый');
console.log(`  sha256 файлов: 0000_init ${сумма('0000_init.sql')}…, 0002_tags ${сумма('0002_tags.sql')}…`);
await журнал();
await таблицы();

// 1. Уже применённую миграцию правят на месте: колонку забыли объявить обязательной
writeFileSync(файл('0000_init.sql'), текст('0000_init.sql').replace('"body" text,', '"body" text NOT NULL,'));
// 2. Ветку слили позже соседней, но время в журнале у неё меньше — как и вышло в ветке
const журналФайл = new URL('./drizzle/meta/_journal.json', import.meta.url);
const записи = JSON.parse(readFileSync(журналФайл, 'utf8'));
записи.entries.splice(1, 0, { idx: 1, version: '7', when: 1758621600000, tag: '0001_authors', breakpoints: true });
writeFileSync(журналФайл, JSON.stringify(записи, null, 2));
console.log(`--- 0000_init поправлен (sha256 стал ${сумма('0000_init.sql')}…), в журнал репозитория вписан 0001_authors`);

заход('второй');
await журнал();
await таблицы();

await client.end();
node edited.ts

Не изменилось ничего, и ни на что migrate не пожаловался. Поправленный файл заново не применён — это как раз правильно; но и расхождения никто не назвал, хотя хеш в журнале уже не совпадает с файлом. Хеш пишут, а не сверяют. Вторая ловушка серьёзнее: миграция 0001_authors не применилась вовсе — и не применится никогда. Правило drizzle-kit — «применить всё, у чего when больше самого позднего created_at в базе», то есть тот самый раннер по порогу из четвёртого раздела; таблицы authors в базе нет ни после второго захода, ни после любого следующего.

Отсюда два рабочих правила. Применённую миграцию не правят — поправка едет новой, с новым номером и новым временем. А перед слиянием ветки смотрят на время последней миграции в главной ветке: если своя старше, её пересоздают — у drizzle-kit это значит убрать файл вместе с записью из _journal.json и прогнать generate заново. Второе правило есть кому проверить: рядом с generate и migrate у drizzle-kit живёт команда check. Она читает не базу, а снимки схемы в каталоге миграций, и выходит с кодом 1, когда два снимка выросли от одного родителя, — то есть на двух ветках, отведённых от одного места и слитых рядом. Первое правило не проверит никто: поправленный задним числом файл check пропускает молча, потому что в базу он и не смотрит. Значит, эта сверка — своя, шагом на выкате: сличить суммы файлов с журналом базы ровно так, как это делал ваш раннер в прошлом разделе.

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

  • Миграция — записанный шаг, а не картинка желаемого: разница двух описаний не отличает переименование колонки от её потери вместе с данными, и выбирает между ними человек. Автоматическая сверка со схемой хороша ровно до первых чужих данных.
  • История миграций лежит в репозитории файлами, а отметка о применённом — в самой базе: репозиторий один на все базы, и каждая догоняет его сама. Отметку кладут после шага и в одной транзакции с ним.
  • Порядок задаёт имя файла, поэтому номер выравнивают нулями: «10» как строка меньше «9». Раннер, который смотрит только на самую позднюю отметку, навсегда пропускает миграцию из ветки, слитой позже соседней.
  • Отметка по имени отвечает «применяли ли эту», контрольная сумма — «применяли ли именно это». Без суммы правка задним числом разводит базу с репозиторием молча, и расхождение всплывает отказом базы там, где по тексту миграций его быть не должно.
  • drizzle-kit generate кладёт в репозиторий SQL-шаг, снимок схемы и meta/_journal.json со временем; drizzle-kit migrate заводит в базе схему drizzle с таблицей __drizzle_migrations и пишет туда hash и created_at.
  • Хеш drizzle-kit пишет, но не сверяет, а применяет всё, у чего when больше самого позднего created_at в базе. Поэтому поправленная миграция проходит молча, а миграция из ветки с меньшим временем не применяется никогда: migrate не скажет ни о той, ни о другой. Вторую ловит отдельная команда drizzle-kit check — по снимкам схемы, а не по базе; первую закрывает только своя сверка сумм на выкате.

1/4Почему история миграций лежит файлами в репозитории, а отметка о применённом — в самой базе?

2/4Раннер отмечает миграцию применённой до того, как выполнит её шаг. Чем это плохо?

3/4Зачем хранить в журнале контрольную сумму миграции, если имя и так уникально?

4/4drizzle-kit записал в drizzle.__drizzle_migrations hash каждой миграции. Что он делает с ним при следующем migrate?

Отметка после шага

Раннер ниже ставит отметку в журнал до того, как выполнит шаг миграции. Во второй миграции опечатка — она просит индекс у таблицы notes, которой в этой базе нет, — и выкат обрывается. Опечатку тут же исправляют, и второй выкат должен взяться за 0002 заново. Сейчас он её пропускает; почините раннер так, чтобы отметку получали только доехавшие миграции.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Строку отметки.insert({ name: миграция.имя }) нужно перенести после миграция.шаг(база):

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

После этого первый выкат обрывается на 0002, но отметки о ней в журнале не остаётся, и второй выкат берётся за неё снова — уже за починенную: «0002_tags_slug: применена».

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

Миграция, которой больше нет

Базу выкатывали, когда в репозитории была миграция 0002_notes_slug. Потом ветку откатили, и файл унесли вместе с ней — а отметка в базе осталась. Раннер этого не замечает и спокойно катит дальше, хотя база уже не соответствует ни одной версии репозитория. Допишите проверку: раннер должен назвать лишнюю отметку по имени и остановиться, ничего не применив.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Вместо // ваш код — проход по отметкам базы со сверкой с именами из репозитория:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь раннер до цикла не доходит: вместо строк про 0001 и 0003 печатается имя пропавшей миграции, и в базе остаётся одна таблица notes.

Останавливаться тут важнее, чем кажется: такая база обычно не «отстала», а ушла вперёд — на ней уже выполнили то, чего в репозитории нет, и следующие миграции писались без оглядки на это. Настоящий drizzle-kit такой сверки не делает вовсе.

Две миграции с одним именем

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Вместо // ваш код — счёт имён по всему списку, до первого шага:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь раннер печатает одну строку и не создаёт ни одной таблицы, тогда как без проверки в базе оказывались notes, tags и authors — последняя без отметки в журнале.

Проверки вида «не трогай базу, пока весь список не осмотрен» стоит делать все разом в начале: сюда же просится сверка сумм из пятого раздела и поиск отметок без файла из второй задачи.