--- topic: db-schema prefix: MIGR --- # Схема и миграции (SQLite, Go) Как пишутся миграции и какие типы колонок выбираются для схемы SQLite в Go-приложении. Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки ПОЧЕМУ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке конвенций версии 1 — тогда и только тогда, когда написаны заглавными. ## Область действия Схема меняется тяжело: таблица не переезжает от того, что её потрогали. Правила распространяются на **новые миграции**; существующая схема не переписывается, и проверяется граница изменения — то, что миграция добавляет, а не то, что уже лежит в базе. ## Миграции ### MIGR-1. Миграции ведёт goose **ДОЛЖЕН.** Набор миграций репозитория применяется одним инструментом — goose. **ПОЧЕМУ.** Журнал применённых версий goose держит в самой базе (`goose_db_version`) и по нему решает, что ещё не накатывалось. Второй инструмент заводит второй журнал: миграция, применённая одним, для другого выглядит неприменённой, и попытка накатить её повторно упирается в уже существующую таблицу. На сервере это означает ручной разбор состояния схемы вместо автоматического деплоя. ### MIGR-2. Файлы миграций лежат рядом со store-слоем **СЛЕДУЕТ.** Миграции хранятся рядом с кодом, который работает с этой схемой. **ПОЧЕМУ.** Миграция и код, читающий схему, — одно изменение: колонка появляется вместе с полем структуры и запросом. Лежащие в другом конце дерева миграции выпадают из поля зрения при правке store, и уезжает либо код без миграции, либо миграция без кода; расходятся они на сервере, где схема ещё старая. ### MIGR-3. Форма миграции выбирается по тому, нужен ли код **ДОЛЖЕН.** Миграция пишется в той форме, которой требует её содержимое: | № | Что делает миграция | Форма | |---|---|---| | MIGR-3.1 | DDL: создание таблиц, индексы, изменение структуры | SQL-файл | | MIGR-3.2 | требует кода: генерация идентификаторов, backfill, перенос данных между формами | Go-миграция (`goose.AddMigrationContext`) | **ПОЧЕМУ.** DDL ничего не вычисляет, и SQL-файл показывает ровно тот текст, который уедет в базу; обёртка на Go вокруг него добавляет место, где можно ошибиться, не добавляя ничего к результату. Обратное направление дороже. Перенос данных и генерация идентификаторов выражаются на SQL либо громоздко, либо неточно: идентификатор по `KEYS-2` порождает приложение, и SQL-миграция вынуждена завести для него второй генератор — ровно то, что запрещает `KEYS-3`. Единообразие формы здесь покупается дублированием логики, которая уже есть в коде. ### MIGR-4. В деплое схема движется только вперёд **НЕ ДОЛЖЕН.** Откат схемы на сервере не выполняется down-миграцией; ошибка исправляется новой миграцией вперёд. **ПОЧЕМУ.** Down на сервере не возвращает прежнее состояние, а имитирует его: колонка, которую убрал up, восстанавливается пустой, а строки, записанные уже по новой схеме, в старую форму не ложатся. Потеря при этом происходит молча — миграция отчитывается об успехе. Исправление, приехавшее следующей миграцией, оставляет целыми и данные, и журнал применённых версий. ### MIGR-5. Down пишется, когда он честно обращает up **ДОЛЖЕН.** Наличие down-миграции определяется тем, обратим ли up: | № | Что делает up | Down | |---|---|---| | MIGR-5.1 | добавляет структуру: таблицу, колонку, индекс | пишется, убирает добавленное | | MIGR-5.2 | необратимо преобразует данные | не пишется | **ПОЧЕМУ.** Down — инструмент разработки, где ветку переключают туда-сюда, и именно там он обязан действительно обращать up. Имитация опаснее отсутствия: разработчик применяет её, получает схему прежней формы и продолжает работу, не заметив, что колонка вернулась пустой. Отсутствующий down останавливает сразу и заставляет пересоздать базу — это дешевле, чем отладка по данным, которых уже нет. ### MIGR-6. ER-схема обновляется в том же изменении **ДОЛЖЕН.** Изменение структуры и правка ER-схемы в спеках едут одним изменением. **ПОЧЕМУ.** Диаграмму читают вместо DDL — в этом весь её смысл. Разошедшаяся с базой, она не бесполезна, а даёт неверный ответ, и заметить это можно, только сверив её с миграциями, то есть проделав работу, которую диаграмма экономит. Отложенное обновление не делается: изменение уже влито, и повода вернуться к схеме больше нет. ## Типы колонок Правила ниже описывают хранение в SQLite: выбор типа диктует движок базы, а не язык приложения. ### MIGR-7. Enum-поля — `TEXT`, допустимые значения держит код **ДОЛЖЕН.** Поле-перечисление (`state`, `kind`, …) объявляется как `TEXT` без `CHECK`-ограничения на список значений. **ПОЧЕМУ.** `ALTER TABLE` в SQLite не умеет менять ограничения ни в одной версии. Поэтому каждое новое значение перечисления в `CHECK (... IN (...))` превращается из строки в коде в пересоздание таблицы по 12-шаговой процедуре, с копированием данных и восстановлением внешних ключей. Платить эту цену не за что: невалидное значение отсекается типами Go раньше, чем дойдёт до вставки, и `CHECK` лишь дублирует защиту, которая всё равно нужна выше. `TEXT` при этом читается в дампе и в логе без таблицы соответствия, которую пришлось бы держать в голове для числового кода. ### MIGR-8. Метки времени — `TEXT` в каноническом формате **ДОЛЖЕН.** Колонка с меткой времени объявляется как `TEXT`, значения пишутся как RFC 3339 в UTC с суффиксом `Z` и фиксированной шириной. **ПОЧЕМУ.** Типа даты в SQLite нет, поэтому единственное, что делает значения сравнимыми, — договорённость о формате; сам формат выбран не здесь, а конвенцией `time` (`TIME-1`, `TIME-2`). Текст в нём сортируется лексикографически в том же порядке, что и хронологически: `ORDER BY` и диапазонные условия работают без функций преобразования, а значит и без потери индекса. Соседство двух форматов в одной колонке ломает и сравнение, и разбор на стороне Go. ### MIGR-9. Умолчание `DEFAULT (datetime('now'))` не ставится **НЕ ДОЛЖЕН.** Колонка с меткой времени не получает значение по умолчанию на уровне схемы. **ПОЧЕМУ.** Время ставит приложение, и умолчание в схеме заводит второй источник этого значения: пропущенное приложением поле не падает, а тихо получает время сервера базы — расхождение обнаруживается по данным, а не по ошибке. Вдобавок `datetime('now')` даёт `YYYY-MM-DD HH:MM:SS` — без `T` и без `Z`, то есть не тот формат, которого требует MIGR-8. В колонке оказываются строки двух видов, и ломается ровно то, ради чего формат выбран. ### MIGR-10. Булевы поля — `INTEGER` со значениями 0 и 1 **ДОЛЖЕН.** Булево значение хранится как `INTEGER` 0/1. **ПОЧЕМУ.** Отдельного булева типа в SQLite нет, поэтому от разнобоя колонку удерживает только договорённость о представлении. Цена ошибки здесь несимметрична: строка `'true'` в булевом контексте приводится к **0**, то есть даёт противоположный ответ, а не пустую выборку и не ошибку типа. Такой дефект не падает, не виден в логе и переживает тесты, которые проверяют, что список не пуст. ### MIGR-11. Первичный ключ новой таблицы — TEXT ULID **ДОЛЖЕН.** Колонка ключа объявляется как `TEXT`, значение приходит из приложения. **ПОЧЕМУ.** Здесь конвенция схемы ничего не решает — она реализует решение, принятое конвенцией `db-identifiers` (`KEYS-1`, `KEYS-2`). Повторить там ветвление или условие значило бы завести второй источник правды, и соседние таблицы разъехались бы по разным ответам на один вопрос. `AUTOINCREMENT` в такой таблице невозможен: SQLite разрешает его только на `INTEGER PRIMARY KEY`. То есть для новых таблиц запрещать нечего. ### MIGR-12. Целочисленный ключ идёт вместе с `AUTOINCREMENT` **ДОЛЖЕН.** Там, где первичный ключ всё-таки целочисленный — существующая схема, миграция легаси-таблицы, — он объявляется с `AUTOINCREMENT`. **ПОЧЕМУ.** Без него SQLite выдаёт rowid как `max(rowid)+1`, поэтому после удаления последней строки номер переиспользуется. Протухшая ссылка на удалённую запись — из закладки, из чужой таблицы, из старого лога — молча наводится на другую сущность и возвращает правдоподобный, но чужой ответ. Обнаружить это по данным нельзя: обе строки валидны. Цена — служебная таблица `sqlite_sequence` и запись в неё на каждой вставке — против этого пренебрежима. Для новых таблиц вопрос не возникает: там ключ строковый (MIGR-11). ## Связано - конвенция `time` — формат меток времени. - конвенция `db-identifiers` — выбор первичных ключей. - конвенция `errors` — граничные ошибки `database/sql` транслируются в доменные у источника, в слое store.