Собираем раннер миграций: журнал, порядок, контрольная сумма
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Прошлая статья кончилась на полуслове: drizzle-kit export печатает готовый CREATE TABLE, но это ещё не миграция. Миграция отличается от печати схемы двумя вещами — её кладут в репозиторий отдельным файлом, а то, что она применена, отмечают в самой базе. Почему не проще: сравнить описание с базой и дописать разницу одной командой? С этого вопроса статья и начинается. Дальше вы соберёте раннер миграций на той же таблице, что и всю главу: сперва он сломается на втором выкате, потом заведёт журнал, потом научится читать имена файлов в нужном порядке, а под конец поймает миграцию, которую поправили уже после применения. И в самом конце — настоящий drizzle-kit: те же файлы, тот же журнал в базе и две ловушки, о которых сам выкат не скажет ни слова.
Описание таблицы лежит в репозитории значением, а в базе — настоящими колонками. Напрашивается простое: сравнить два списка и дописать разницу. Такая команда у Drizzle есть — drizzle-kit push, — и для черновика на своей машине она удобна: правите схему, жмёте push, база догоняет. Беда начинается там, где в таблице лежат чужие данные.
Вот вся задача, которую решает автоматическая сверка. В репозитории колонку переименовали: была title, стала heading. Сверка видит два списка имён — и больше ничего. Прогноз: сколько у неё способов привести базу к новому описанию и чем эти способы отличаются для тех, кто уже написал заметки?
Обе перемены законны, обе приводят колонки к описанию — только одна сохранила заголовки, а другая раздала всем null. Выбрать между ними сверка не может: в схеме написано, что должно получиться, и не написано, как туда идти. Разницу между «переименовали» и «удалили и завели новую» знает лишь человек, который правил схему.
push хорош, пока база пуста: черновик на своей машине переживёт и потерянную колонку. Боевая база чужие заметки не переживёт.
Отсюда и берётся миграция. Миграция — это не картинка «как должно стать», а записанный шаг «что сделать». Его пишут один раз, руками или генератором, и кладут в репозиторий файлом: рядом с кодом, под тем же ревью, в той же истории. Дальше по этим файлам догоняют все базы — ваша, соседа, тестовая и боевая.
Соберём раннер: список миграций и цикл, который их выполняет. База в модели — карта «имя таблицы → таблица»: сами таблицы те, что вы собирали в начале главы, а карта над ними здесь новая. Шаг миграции — обычная функция, которая с этой картой что-то делает. Создание таблицы у настоящей базы не идемпотентно: CREATE TABLE notes во второй раз отвечает кодом 42P07, и модель повторяет это дословно.
Первый выкат идёт по пустой базе, потом в таблицу ложится заметка, а потом тот же код выкатывают ещё раз — обычное дело, если миграции прогоняют на каждый деплой. Прогноз: сколько миграций успеет пройти второй выкат и сколько заметок останется в таблице?
Второй выкат оборвался на первой же миграции, и это лучший из возможных исходов: база ответила отказом, заметка цела, деплой красный. Хуже было бы, если бы создание таблицы молча заменило старую новой — тогда заметка исчезла бы, и никто бы не заметил.
Соблазн понятный: написать каждую миграцию так, чтобы её можно было повторять, — CREATE TABLE IF NOT EXISTS, ADD COLUMN IF NOT EXISTS. Он не спасает.
Половина миграций — не создание, а перенос данных: разложить полное имя на два поля, проставить slug всем старым заметкам, перевести суммы из рублей в копейки. У такого шага нет формы «если ещё не сделано»: UPDATE notes SET amount = amount * 100, выполненный дважды, даёт не копейки, а в сто раз больше, и никакой IF NOT EXISTS про это не знает. Признак «применено» приходится хранить отдельно.
Значит, раннеру нужна память: какие шаги эта база уже видела.
Где хранить список применённого? Файл рядом с кодом не годится: репозиторий один, а баз несколько — ваша, соседа, тестовая, боевая. Такой файл сказал бы «0003 применена» всем сразу, хотя видела её только ваша машина. Список принадлежит не коду, а базе — поэтому и лежит в базе, обычной таблицей, которую раннер заводит себе сам при первом выкате.
Лестницу миграций вы уже проходили на телефоне: там номер схемы лежал в том же значении, что и данные, и шаги шли подряд от него. Механика та же, спрашивают о другом: телефон один и база у него одна, а здесь один репозиторий выкатывают в несколько баз, и «докуда дошли» каждая отвечает сама. Отсюда и таблица вместо одного числа: у отметки появится и имя, и контрольная сумма.
В модели журнал — та же таблица, с уникальным индексом по имени: дважды отметить одну миграцию она не даст. Прогноз: что напечатает сегодняшний выкат на машине, где вчера прогнали первые две миграции, и что — на боевой базе, заведённой сегодня с нуля?
Код один, список миграций один, а сделал он разное — потому что спрашивал не репозиторий, а базу. dev догнал репозиторий одной миграцией, prod — тремя, и в конце обе базы в одном состоянии. Это и есть главное свойство миграций: история общая, а сколько из неё применено — свой ответ у каждой базы.
Таблицу журнала обычно называют с подчёркивания или прячут в отдельную схему, чтобы она не мешалась среди таблиц приложения.
Шаг и отметка должны попадать в базу вместе. Если шаг доехал, а отметка нет, следующий выкат повторит его со всеми последствиями из прошлого раздела; если отметка легла раньше шага, а шаг упал, база останется без него навсегда — с этого начинается первая задача. В Postgres команды вроде CREATE TABLE работают внутри транзакции, так что оба действия кладут в одну транзакцию — ту самую, которую вы собирали в статье про откат.
Раннер берёт миграции из каталога, а каталог отдаёт файлы в своём порядке. Значит, порядок раннер задаёт сам, и единственное, что у него для этого есть, — имя файла. Отсюда правило, которое выглядит бюрократией, пока в него не упрёшься: имя начинается с номера, а номер выровнен нулями.
Прогноз: три набора имён, три прогона — какие из них доедут до конца и на чём споткнутся остальные?
Имена по смыслу сортируются по алфавиту: add-slug-index встаёт перед create-notes, и индекс просят у таблицы, которой ещё нет, — 42P01. Номер порядок чинит, но только пока он одной ширины: «10» и «9» сравниваются как строки, знак за знаком, и «1» меньше «9». Поэтому номер и выравнивают нулями: у drizzle-kit он четырёхзначный, и первая миграция называется 0000, а не 1.
Но номер — не единственный способ расставить миграции. Drizzle держит рядом с файлами собственный журнал репозитория, и в нём у каждой миграции записано время, и порядок раннер берёт оттуда, а не из имён файлов. Раннер может сравнивать с базой набор имён, а может смотреть только на самую позднюю отметку и применять всё, что новее. Пока ветки сливают по очереди, разницы между этими правилами не видно. Прогноз: у какого из двух раннеров в базе появится таблица authors?
Раннер по порогу пропустил 0001 навсегда: её время меньше времени последней применённой, и следующий выкат рассудит так же. Раннер по именам увидел, что такого имени в журнале нет, и применил. Оба правила простые, но первое делает молчаливое допущение — что миграции приезжают в порядке времени, — и ветка, слитая позже соседней, это допущение ломает. Какое из двух правил у настоящего drizzle-kit, увидим в последнем разделе.
Отметка по имени отвечает на вопрос «эту миграцию уже применяли?». Она не отвечает на другой: «применяли ли именно это?». Имя файла держится, а текст внутри может смениться: кто-то заметил опечатку, кто-то «просто поправил» вчерашнюю миграцию вместо того, чтобы написать новую. База при этом остаётся такой, какой её сделал прежний текст.
Лекарство — хранить в журнале не только имя, но и контрольную сумму текста. Настоящий раннер берёт sha256 от файла; в модели миграция — функция, и суммой послужит хеш её исходника: важна не стойкость, а то, что число меняется вместе с текстом. Прогноз: что напечатает про 0002 второй выкат и что после него окажется в базе?
Раннер назвал файл и обе суммы — этого хватает, чтобы понять, что репозиторий и база разошлись. А последняя строка показывает цену молчания: в базе стоит уникальный индекс, которого в репозитории уже нет, и вторая заметка с тем же slug отказывает кодом 23505 — хотя по нынешнему тексту миграции уникальности там быть не должно.
Откатить и применить заново — плохой ответ: на боевой базе откат применённой миграции означает DROP того, чем прямо сейчас пользуются. Ответ скучный: поправка едет новой миграцией, с новым номером. Старая остаётся в истории такой, какой её применили, — история на то и история. Редактировать можно ровно одну миграцию: ту, которую вы ещё никуда не выкатывали, включая собственную машину.
Всё собранное выше есть у drizzle-kit, и под теми же именами. Начнём с того, чем generate отличается от export из прошлой статьи: export печатает схему целиком в поток — посмотреть и закрыть, — а 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');
No config path provided, using default 'drizzle.config.ts'
Reading config file '<пример>/drizzle.config.ts'
1 tables
notes 7 columns 0 indexes 0 fks
[✓] Your SQL migration file ➜ drizzle/0000_init.sql 🚀
--- что появилось в drizzle/
0000_init.sql
meta
meta/0000_snapshot.json
meta/_journal.json
--- drizzle/meta/_journal.json
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": <время>,
"tag": "0000_init",
"breakpoints": true
}
]
}
--- drizzle/0000_init.sql
CREATE TABLE "notes" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "notes_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"title" varchar(80) NOT NULL,
"author" text NOT NULL,
"body" text,
"status" text DEFAULT 'draft' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"edited_at" timestamp with time zone NOT NULL
);Файлов три. 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 и прогоняет то, чего в ней ещё нет. Запустим её дважды подряд на одной базе, с двумя миграциями в каталоге. Прогноз: что напечатает второй заход и сколько строк окажется в таблице журнала?
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();
база: postgres://postgres@127.0.0.1:<порт-базы>/app
--- первый: drizzle-kit migrate
No config path provided, using default 'drizzle.config.ts'
Reading config file '<пример>/drizzle.config.ts'
Using 'pg' driver for database querying
[~] applying migrations...
[✓] migrations applied successfully!
таблицы базы: drizzle.__drizzle_migrations, public.notes
в drizzle.__drizzle_migrations строк: 2
id 1, hash 5859b94643cbf56c…, created_at 1758618000000
id 2, hash ee200cff0b43b83b…, created_at 1758621600000
--- второй: drizzle-kit migrate
No config path provided, using default 'drizzle.config.ts'
Reading config file '<пример>/drizzle.config.ts'
Using 'pg' driver for database querying
[~] applying migrations...
[✓] migrations applied successfully!
в drizzle.__drizzle_migrations строк: 2
id 1, hash 5859b94643cbf56c…, created_at 1758618000000
id 2, hash ee200cff0b43b83b…, created_at 1758621600000Второй заход не применил ничего — и сказал ровно то же, что первый: «migrations applied successfully!». Это первое, что стоит запомнить про любой раннер миграций: успешное сообщение не значит, что что-то применили, и сколько именно применено, видно только по журналу. В журнале две строки, по одной на миграцию: created_at — это when из журнала репозитория, перенесённое как есть, а hash — sha256 от всего текста файла.
Раз хеш записан, кажется, что drizzle-kit сверяет тексты. Проверим обе ловушки разом. Сначала поправим уже применённую миграцию — допишем колонке body слово NOT NULL. Потом впишем в журнал репозитория миграцию с временем между двумя уже применёнными: так выглядит ветка, которую отвели раньше соседней, а слили позже. Прогноз: что скажет второй заход migrate, что станет с журналом базы и появится ли таблица authors?
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();
база: postgres://postgres@127.0.0.1:<порт-базы>/app --- первый заход: drizzle-kit migrate No config path provided, using default 'drizzle.config.ts' Reading config file '<пример>/drizzle.config.ts' Using 'pg' driver for database querying [~] applying migrations... [✓] migrations applied successfully! sha256 файлов: 0000_init 5859b94643cb…, 0002_tags 84915a46da8c… журнал базы: hash 5859b94643cb…, created_at 1758618000000 журнал базы: hash 84915a46da8c…, created_at 1758625200000 таблицы: notes, tags; notes.body is_nullable = YES --- 0000_init поправлен (sha256 стал 61f3f6712631…), в журнал репозитория вписан 0001_authors --- второй заход: drizzle-kit migrate No config path provided, using default 'drizzle.config.ts' Reading config file '<пример>/drizzle.config.ts' Using 'pg' driver for database querying [~] applying migrations... [✓] migrations applied successfully! журнал базы: hash 5859b94643cb…, created_at 1758618000000 журнал базы: hash 84915a46da8c…, created_at 1758625200000 таблицы: notes, tags; notes.body is_nullable = YES
Не изменилось ничего, и ни на что 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 заново. Сейчас он её пропускает; почините раннер так, чтобы отметку получали только доехавшие миграции.
Строку отметки.insert({ name: миграция.имя }) нужно перенести после миграция.шаг(база):
После этого первый выкат обрывается на 0002, но отметки о ней в журнале не остаётся, и второй выкат берётся за неё снова — уже за починенную: «0002_tags_slug: применена».
В настоящей базе этого мало: между шагом и отметкой процесс может умереть, и шаг останется применённым без отметки. Поэтому оба действия кладут в одну транзакцию — тогда либо в базе есть и таблица, и отметка, либо нет ни того, ни другого.
Миграция, которой больше нет
Базу выкатывали, когда в репозитории была миграция 0002_notes_slug. Потом ветку откатили, и файл унесли вместе с ней — а отметка в базе осталась. Раннер этого не замечает и спокойно катит дальше, хотя база уже не соответствует ни одной версии репозитория. Допишите проверку: раннер должен назвать лишнюю отметку по имени и остановиться, ничего не применив.
Вместо // ваш код — проход по отметкам базы со сверкой с именами из репозитория:
Теперь раннер до цикла не доходит: вместо строк про 0001 и 0003 печатается имя пропавшей миграции, и в базе остаётся одна таблица notes.
Останавливаться тут важнее, чем кажется: такая база обычно не «отстала», а ушла вперёд — на ней уже выполнили то, чего в репозитории нет, и следующие миграции писались без оглядки на это. Настоящий drizzle-kit такой сверки не делает вовсе.
Две миграции с одним именем
Две ветки взяли один и тот же номер, и слияние сложило оба файла рядом. Уникальный индекс журнала отказывает второму — но уже после того, как его шаг выполнен, и в базе остаётся таблица, о которой журнал ничего не знает. Допишите раннеру проверку, которая ловит одинаковые имена до того, как он тронет базу.
Вместо // ваш код — счёт имён по всему списку, до первого шага:
Теперь раннер печатает одну строку и не создаёт ни одной таблицы, тогда как без проверки в базе оказывались notes, tags и authors — последняя без отметки в журнале.
Проверки вида «не трогай базу, пока весь список не осмотрен» стоит делать все разом в начале: сюда же просится сверка сумм из пятого раздела и поиск отметок без файла из второй задачи.