- 31 регион `<!-- local:имя -->` в двенадцати файлах удалён, а не перенесён: локальное принадлежит копии и живёт ниже маркера `<!-- conv:local -->` (META-22), так что наполнять регионы в каноне нечем - вместе с ними ушли два опустевших раздела «Связано» — в arch и ansible слоях app-directories канонических ссылок нет, а пустой заголовок ничего не адресует; CLAUDE.md уточнён: раздел заводят, когда ссылки есть - форма проверена скриптом: у всех правил модальность и «Почему», префиксы сходятся с реестром, дыр в нумерации нет
198 lines
14 KiB
Markdown
198 lines
14 KiB
Markdown
---
|
|
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.
|