# Миграции схемы БД: `_schema_migrations`, не Alembic > Decision-doc по issue [#72](https://git.gendsgn.ru/lekss361/gendesign/issues/72) > (B5-2). Дата: 2026-06-13. Статус: **ACCEPTED**. > > Снапшот — источник правды по решениям живёт в Obsidian vault > (`decisions/`). Здесь — версионированная в репозитории копия рядом с кодом, > на который она ссылается. ## TL;DR - Схема прод-БД развивается через **идемпотентные `data/sql/NN_*.sql`**, которые `deploy.yml` (Forgejo Actions) прогоняет автоматически и ровно один раз каждый, отмечая применённые в таблице-трекере **`_schema_migrations`**. - **Alembic в проде НЕ используется.** `backend/alembic/versions/` содержит только `.gitkeep` — за всё время не создано ни одной ревизии. `alembic upgrade head` на проде — no-op (нет ревизий), а `alembic revision --autogenerate` рассинхронизирован с реальной схемой (модели в `app/models/` описывают лишь крошечную часть из ~50 таблиц). - **Рекомендация (принята): удалить пустой Alembic-скелет** (`backend/alembic/`, `backend/alembic.ini`, dep `alembic` в `pyproject.toml`) — он не несёт функции и вводит в заблуждение («есть Alembic → значит миграции через него»). Если в будущем понадобится ORM-driven migration tool — переустановить осознанно. - Формулировка из тела issue про «синхронизацию состояния Alembic с прод» (`alembic current` / `alembic stamp head` / catch-up migration) **неактуальна**: синхронизировать нечего — Alembic никогда не был источником схемы. ## Как реально работают миграции ### Артефакт: `data/sql/NN_*.sql` ~118 файлов (на 2026-06-13), нумерованных `NN_описание.sql` (`01_…`, `144_…`). Каждый — самодостаточный idempotent DDL/ETL-фрагмент: ```sql -- 144_audit_log.sql -- Deploy: auto-applied deploy.yml через _schema_migrations (ровно один раз NN=144). -- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS + guarded DO-блок для CHECK. BEGIN; CREATE TABLE IF NOT EXISTS audit_log ( ... ); COMMENT ON TABLE audit_log IS '...'; COMMIT; ``` Правила для нового файла: 1. **Имя** — следующий свободный номер + snake_case описание: `NNN_<что>.sql`. Порядок применения = лексикографическая сортировка `ls -1 | sort`, поэтому зануляй ширину при необходимости (`089_…` vs `89_…` сортируются по-разному — придерживайся существующей ширины в каталоге). 2. **Идемпотентность** — `CREATE … IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS`, `CREATE OR REPLACE VIEW`, `ON CONFLICT DO NOTHING`, guarded `DO $$ … $$`-блоки для того, что не поддерживает `IF NOT EXISTS` (CHECK-констрейнты, ENUM-значения). Файл может выполниться повторно вручную без вреда (хотя deploy и так пропустит уже применённый). 3. **psycopg v3 / SQLAlchemy** — в коде приложения только `CAST(:x AS type)`, никогда `:x::type` (см. `.claude/rules/backend.md`). В самих `.sql`-файлах, которые гоняет `psql`, `::type`-каст допустим — это чистый psql, не через SQLAlchemy bind-params. 4. **Транзакционность** — оборачивай связанные изменения в `BEGIN; … COMMIT;`. `psql -v ON_ERROR_STOP=on`: при ошибке деплой падает (`exit 1`) и контейнеры НЕ обновляются (схема и образы остаются консистентны). ### Трекер: таблица `_schema_migrations` ```sql CREATE TABLE IF NOT EXISTS _schema_migrations ( filename TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ``` `filename` — basename файла (`144_audit_log.sql`). Применённость = наличие строки. ### Раннер: `.forgejo/workflows/deploy.yml` После `git reset --hard origin/main` и `compose pull`, ДО `compose up -d`: ```bash # 1) гарантировать трекер psql … -c "CREATE TABLE IF NOT EXISTS _schema_migrations ( filename TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() );" # 2) применить каждый ещё-не-применённый файл в порядке сортировки for sql_file in $(ls -1 data/sql/*.sql 2>/dev/null | sort); do fname=$(basename "$sql_file") applied=$(psql … -tAc "SELECT COUNT(*) FROM _schema_migrations WHERE filename='$fname'") if [ "$applied" = "0" ]; then echo "→ Applying migration: $fname" psql … -v ON_ERROR_STOP=on < "$sql_file" \ || { echo "FAILED on migration: $fname"; exit 1; } psql … -c "INSERT INTO _schema_migrations (filename) VALUES ('$fname') ON CONFLICT DO NOTHING;" else echo "✓ Already applied: $fname" fi done ``` Ключевые свойства: - **Только `*.sql`** трекаются/применяются. `data/sql/*.py` и `*.sh` (ad-hoc загрузчики, напр. `02_load_all_quarters.sh`, `40_scrape_history.py`) раннер игнорирует — они запускаются вручную. - **Идемпотентность на двух уровнях**: трекер пропускает уже применённое; сам SQL безопасен к повторному прогону (на случай ручного запуска или восстановления). - **Fail-fast**: первый упавший файл останавливает деплой, образы не катятся. - **Триггер деплоя**: путь `data/sql/**` в `on.push.paths` `deploy.yml` (правка только `docs/**` деплой НЕ триггерит — см. `.claude/rules/deploy.md`). ### Bootstrap (исторический, разовый) `scripts/bootstrap_schema_migrations.sh` сидировал `_schema_migrations` всеми файлами, уже применёнными на проде ДО введения auto-apply (PR #150) — чтобы первый auto-deploy не пытался переиграть всю историю. Запускался один раз вручную через SSH-туннель. Для новых файлов больше не нужен — deploy.yml сам ведёт трекер. ## Почему raw SQL, а не Alembic | | `data/sql/NN_*.sql` (используется) | Alembic (скелет, не используется) | |---|---|---| | Что описывает | Реальную прод-схему (~50 таблиц, partitions, GIST, materialized views, PostGIS, FDW, роли, гранты) | Только `Base.metadata` из `app/models/` — крошечная часть (1 модуль `parcel`) | | PostGIS / big views / partitions | Нативно — пишем DDL как есть | autogenerate их не видит/ломает (geoalchemy типы, `CREATE MATERIALIZED VIEW`, partitioning) | | Идемпотентность | Встроена (`IF NOT EXISTS`, guarded `DO`) | Линейная цепочка ревизий, down/up | | Источник истины | Файлы в репе + `_schema_migrations` | `alembic_version` (в проде таблицы нет) | | Применение | `deploy.yml` авто, fail-fast | Не подключён к деплою | Проект на старте — data-heavy: партиционированный `rosreestr_deals` (~7M строк), PostGIS-геометрии, materialized views, cross-DB FDW в tradein, роли/гранты. Это **deployment artifacts**, которые естественно описывать прямым DDL. ORM-модели покрывают лишь endpoints приложения, а не аналитический слой. Alembic autogenerate на такой схеме генерирует мусор (drop неизвестных ему объектов) — поэтому им и не пользовались. ## Граница: что куда - **`data/sql/NN_*.sql`** — ВСЁ изменение схемы прод-БД сейчас идёт сюда: - новые таблицы / колонки / индексы / констрейнты; - materialized views и обычные views (`v_*`, `mv_*`); - partitions `rosreestr_deals`; - роли, гранты, FDW-bootstrap; - data-backfill, который должен прогоняться ровно один раз. - Владелец правок: **`database-expert`** subagent (см. `.claude/rules/sql.md`). - **Alembic** — НЕ используется. (Гипотетически: если бы в будущем понадобилась чистая ORM-driven эволюция небольшого набора app-таблиц с автогенерацией — это был бы кандидат. Сейчас такой потребности нет, и смешение двух раннеров на одной схеме опаснее единого пути.) ## Рекомендация по пустому Alembic-скелету **Удалить**, чтобы убрать ложный сигнал «миграции через Alembic»: - `backend/alembic/` (env.py, script.py.mako, versions/.gitkeep) - `backend/alembic.ini` - dep `alembic>=1.13.0` из `backend/pyproject.toml` + пересборка лок-файла (`cd backend && uv lock`) - таргеты `make migrate` (`alembic upgrade head`) и `make migration NAME=…` (`alembic revision --autogenerate`) в корневом `Makefile` — оба зовут Alembic и сейчас вводят в заблуждение (на проде upgrade — no-op, autogenerate рассинхронизирован со схемой). Заменить на `make migrate-status` поверх `_schema_migrations` либо удалить. > Это правка `pyproject.toml` + удаление файлов под `backend/` — выполняется > отдельным PR (`chore(backend): drop unused Alembic skeleton`), вне scope чисто > документационного #72, чтобы не смешивать docs-only изменение с правкой > зависимостей/Dockerfile-кэша. Здесь — зафиксированное **решение**; реализацию > сноса делает backend-engineer отдельным PR после approve этого decision-doc. Альтернатива (если решат оставить Alembic «на будущее»): держать скелет, но добавить в `backend/alembic/README` явную пометку «NOT WIRED — схема через `data/sql` + `_schema_migrations`, см. `docs/Schema_Migrations_vs_Alembic.md`», чтобы новый разработчик не пытался `alembic upgrade head`. ## Ссылки - Раннер: `.forgejo/workflows/deploy.yml` (шаг "Apply pending SQL migrations") - Bootstrap: `scripts/bootstrap_schema_migrations.sh` - Конвенции SQL/деплоя: `.claude/rules/sql.md`, `.claude/rules/deploy.md`, `.claude/rules/backend.md` - Скелет (к удалению): `backend/alembic/`, `backend/alembic.ini`